Wikilivres
frwikibooks
https://fr.wikibooks.org/wiki/Accueil
MediaWiki 1.47.0-wmf.24
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
Fonctionnement d'un ordinateur/Le codage des nombres
0
65779
775304
755732
2026-10-14T19:14:48Z
Mewtow
31375
/* Les nombres à virgule */
775304
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle. Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande. Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
===Les nombres à virgule non-représentables en binaire===
Quel que soit la façon de représenter les nombres à virgules, il existe des nombres qui ne peuvent être représentés de manière exacte, à savoir avec un nombre fini de décimales après la virgule. En soi, ce n'est pas spécifique au binaire, on a la même chose en décimal. Par exemple, la fraction 1/3 en décimal s'écrit 0.3333333..., avec une infinité de 3. La même chose existe en binaire, mais pour des nombres différents.
Déjà, évacuons le cas des nombres irrationnels, à savoir les nombres qui ne peuvent pas s'écrire sous la forme d'une fraction, comme <math>pi</math> ou <math>e</math>. Ils ont une infinité de décimales que ce soit en binaire, en décimal, en hexadécimal, ou autre. Ils ne sont pas représentables avec un nombre fini de décimales quelle que soit la base utilisée. Concentrons-nous sur des nombres qui ne sont pas dans ce cas, et qui ont un nombre fini ou infini de décimales.
Le passage de la base 10 à la base 2 change le nombre de décimales, et peut faire passer d'un nombre fini de décimales à un nombre infini. Par exemple, <math>\frac{1}{5} = 0.2</math> est représenté en binaire avec une séquence infinie de bits : <math>0.001100110011...</math>. Les nombres étant en base binaire et représenté avec un nombre limité de bits, il existe certains nombres décimaux triviaux qui ne sont pas représentables avec un nombre fini de décimales.
Et la réciproque n'est pas vraie : tout nombre binaire avec un nombre fini de décimales en binaire est représentable avec un nombre fini de décimales en base 10. Ceci est lié à la décomposition en facteur premier des bases utilisées :
* <math>2 = 2^1</math>
* <math>10 = 2^1 \times 5^1</math>
Le nombre 10 possède tous les facteurs premiers de 2, mais 2 n'a pas de 5 dans sa décomposition.
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
jlaubtn0f9ae3mtdz0n9302bvanhlg7
775305
775304
2026-10-14T19:15:53Z
Mewtow
31375
/* Les nombres à virgule fixe */
775305
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
===La virgule fixe : des entiers cachés===
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle. Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande. Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
===Les nombres à virgule non-représentables en binaire===
Quel que soit la façon de représenter les nombres à virgules, il existe des nombres qui ne peuvent être représentés de manière exacte, à savoir avec un nombre fini de décimales après la virgule. En soi, ce n'est pas spécifique au binaire, on a la même chose en décimal. Par exemple, la fraction 1/3 en décimal s'écrit 0.3333333..., avec une infinité de 3. La même chose existe en binaire, mais pour des nombres différents.
Déjà, évacuons le cas des nombres irrationnels, à savoir les nombres qui ne peuvent pas s'écrire sous la forme d'une fraction, comme <math>pi</math> ou <math>e</math>. Ils ont une infinité de décimales que ce soit en binaire, en décimal, en hexadécimal, ou autre. Ils ne sont pas représentables avec un nombre fini de décimales quelle que soit la base utilisée. Concentrons-nous sur des nombres qui ne sont pas dans ce cas, et qui ont un nombre fini ou infini de décimales.
Le passage de la base 10 à la base 2 change le nombre de décimales, et peut faire passer d'un nombre fini de décimales à un nombre infini. Par exemple, <math>\frac{1}{5} = 0.2</math> est représenté en binaire avec une séquence infinie de bits : <math>0.001100110011...</math>. Les nombres étant en base binaire et représenté avec un nombre limité de bits, il existe certains nombres décimaux triviaux qui ne sont pas représentables avec un nombre fini de décimales.
Et la réciproque n'est pas vraie : tout nombre binaire avec un nombre fini de décimales en binaire est représentable avec un nombre fini de décimales en base 10. Ceci est lié à la décomposition en facteur premier des bases utilisées :
* <math>2 = 2^1</math>
* <math>10 = 2^1 \times 5^1</math>
Le nombre 10 possède tous les facteurs premiers de 2, mais 2 n'a pas de 5 dans sa décomposition.
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
tvc7obaghm66b1t37x30n4qvuf7lkai
775306
775305
2026-10-14T19:16:04Z
Mewtow
31375
/* Les nombres à virgule non-représentables en binaire */
775306
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
===La virgule fixe : des entiers cachés===
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle. Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande. Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
aj6kcd5ek72ykxstqe5pcomcys5s6qc
775307
775306
2026-10-14T19:16:14Z
Mewtow
31375
/* La virgule fixe : des entiers cachés */
775307
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
===La virgule fixe : des entiers cachés===
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
===Les nombres à virgule non-représentables en binaire===
Quel que soit la façon de représenter les nombres à virgules, il existe des nombres qui ne peuvent être représentés de manière exacte, à savoir avec un nombre fini de décimales après la virgule. En soi, ce n'est pas spécifique au binaire, on a la même chose en décimal. Par exemple, la fraction 1/3 en décimal s'écrit 0.3333333..., avec une infinité de 3. La même chose existe en binaire, mais pour des nombres différents.
Déjà, évacuons le cas des nombres irrationnels, à savoir les nombres qui ne peuvent pas s'écrire sous la forme d'une fraction, comme <math>pi</math> ou <math>e</math>. Ils ont une infinité de décimales que ce soit en binaire, en décimal, en hexadécimal, ou autre. Ils ne sont pas représentables avec un nombre fini de décimales quelle que soit la base utilisée. Concentrons-nous sur des nombres qui ne sont pas dans ce cas, et qui ont un nombre fini ou infini de décimales.
Le passage de la base 10 à la base 2 change le nombre de décimales, et peut faire passer d'un nombre fini de décimales à un nombre infini. Par exemple, <math>\frac{1}{5} = 0.2</math> est représenté en binaire avec une séquence infinie de bits : <math>0.001100110011...</math>. Les nombres étant en base binaire et représenté avec un nombre limité de bits, il existe certains nombres décimaux triviaux qui ne sont pas représentables avec un nombre fini de décimales.
Et la réciproque n'est pas vraie : tout nombre binaire avec un nombre fini de décimales en binaire est représentable avec un nombre fini de décimales en base 10. Ceci est lié à la décomposition en facteur premier des bases utilisées :
* <math>2 = 2^1</math>
* <math>10 = 2^1 \times 5^1</math>
Le nombre 10 possède tous les facteurs premiers de 2, mais 2 n'a pas de 5 dans sa décomposition.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle. Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande. Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
howhm7fw9y7ny6sno6ajyw65qlj45ps
775308
775307
2026-10-14T19:16:57Z
Mewtow
31375
/* Le codage des nombres flottants et la norme IEEE 754 */
775308
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
===La virgule fixe : des entiers cachés===
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
===Les nombres à virgule non-représentables en binaire===
Quel que soit la façon de représenter les nombres à virgules, il existe des nombres qui ne peuvent être représentés de manière exacte, à savoir avec un nombre fini de décimales après la virgule. En soi, ce n'est pas spécifique au binaire, on a la même chose en décimal. Par exemple, la fraction 1/3 en décimal s'écrit 0.3333333..., avec une infinité de 3. La même chose existe en binaire, mais pour des nombres différents.
Déjà, évacuons le cas des nombres irrationnels, à savoir les nombres qui ne peuvent pas s'écrire sous la forme d'une fraction, comme <math>pi</math> ou <math>e</math>. Ils ont une infinité de décimales que ce soit en binaire, en décimal, en hexadécimal, ou autre. Ils ne sont pas représentables avec un nombre fini de décimales quelle que soit la base utilisée. Concentrons-nous sur des nombres qui ne sont pas dans ce cas, et qui ont un nombre fini ou infini de décimales.
Le passage de la base 10 à la base 2 change le nombre de décimales, et peut faire passer d'un nombre fini de décimales à un nombre infini. Par exemple, <math>\frac{1}{5} = 0.2</math> est représenté en binaire avec une séquence infinie de bits : <math>0.001100110011...</math>. Les nombres étant en base binaire et représenté avec un nombre limité de bits, il existe certains nombres décimaux triviaux qui ne sont pas représentables avec un nombre fini de décimales.
Et la réciproque n'est pas vraie : tout nombre binaire avec un nombre fini de décimales en binaire est représentable avec un nombre fini de décimales en base 10. Ceci est lié à la décomposition en facteur premier des bases utilisées :
* <math>2 = 2^1</math>
* <math>10 = 2^1 \times 5^1</math>
Le nombre 10 possède tous les facteurs premiers de 2, mais 2 n'a pas de 5 dans sa décomposition.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle.
Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande.
Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
jhp0v1yfa8otrm4x88jausjtsqqtcqm
775309
775308
2026-10-14T19:18:42Z
Mewtow
31375
/* Le codage des nombres flottants et la norme IEEE 754 */
775309
wikitext
text/x-wiki
Dans le chapitre précédent, nous avons vu que les ordinateurs actuels utilisent un codage binaire. Ce codage binaire ne vous est peut-être pas familier. Aussi, dans ce chapitre, nous allons apprendre comment coder des nombres en binaire. Nous allons commencer par le cas le plus simple : les nombres positifs. Par la suite, nous aborderons les nombres négatifs. Et nous terminerons par les nombres à virgules, appelés aussi nombres flottants.
==Le codage des nombres entiers positifs==
Pour coder des nombres entiers positifs, il existe plusieurs méthodes : le binaire, l’hexadécimal, le code Gray, le décimal codé binaire et bien d'autres encore. La plus connue est certainement le binaire, secondée par l'hexadécimal, les autres étant plus anecdotiques. Pour comprendre ce qu'est le binaire, il nous faut faire un rappel sur les nombres entiers tel que vous les avez appris en primaire, à savoir les entiers écrits en décimal. Prenons un nombre écrit en décimal : le chiffre le plus à droite est le chiffre des unités, celui à côté est pour les dizaines, suivi du chiffre des centaines, et ainsi de suite. Dans un tel nombre :
* on utilise une dizaine de chiffres, de 0 à 9 ;
* chaque chiffre est multiplié par une puissance de 10 : 1, 10, 100, 1000, etc. ;
* la position d'un chiffre dans le nombre indique par quelle puissance de 10 il faut le multiplier : le chiffre des unités doit être multiplié par 1, celui des dizaines par 10, celui des centaines par 100, et ainsi de suite.
Exemple avec le nombre 1337 :
<math>1337 = 1 \times 1000 + 3 \times 100 + 3 \times 10 + 7 \times 1</math>
Pour résumer, un nombre en décimal s'écrit comme la somme de produits, chaque produit multipliant un chiffre par une puissance de 10. On dit alors que le nombre est en base 10.
<math>1337 = 1 \times 10^3 + 3 \times 10^2 + 3 \times 10^1 + 7 \times 10^0</math>
Ce qui peut être fait avec des puissances de 10 peut être fait avec des puissances de 2, 3, 4, 125, etc : n'importe quel nombre entier strictement positif peut servir de '''base'''. En informatique, on utilise rarement la base 10 à laquelle nous sommes tant habitués. On utilise à la place deux autres bases :
* La base 2 (système binaire) : les chiffres utilisés sont 0 et 1 ;
* La base 16 (système hexadécimal) : les chiffres utilisés sont 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 ; auxquels s'ajoutent les six premières lettres de notre alphabet : A, B, C, D, E et F.
===Le système binaire===
En binaire, on compte en base 2. Cela veut dire qu'au lieu d'utiliser des puissances de 10 comme en décimal, on utilise des puissances de deux : n'importe quel nombre entier peut être écrit sous la forme d'une somme de puissances de 2. Par exemple 6 s'écrira donc 0110 en binaire : <math>0 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 6</math>. On peut remarquer que le binaire n'autorise que deux chiffres, à savoir 0 ou 1 : ces chiffres binaires sont appelés des '''bits''' (abréviation de Binary Digit). Pour simplifier, on peut dire qu'un bit est un truc qui vaut 0 ou 1. Pour résumer, tout nombre en binaire s'écrit sous la forme d'un produit entre bits et puissances de deux de la forme :
<math>a_0 \times 2^0 + a_1 \times 2^1 + a_2 \times 2^2 + a_3 \times 2^3 + a_4 \times 2^4 +...+ a_n \times 2^n</math>
Les coefficients <math>a_0, a_1, a_2</math> sont les bits, l'exposant n qui correspond à un bit <math>a_n</math> est appelé le '''poids''' du bit.
====La terminologie du binaire====
En informatique, il est rare que l'on code une information sur un seul bit. Dans la plupart des cas, l'ordinateur manipule des nombres codés sur plusieurs bits. Les informaticiens ont donné des noms aux groupes de bits suivant leur taille. Le plus connu est certainement l''''octet''', qui désigne un groupe de 8 bits. Moins connu, on parle de '''''nibble''''' pour un groupe de 4 bits (un demi-octet), de '''doublet''' pour un groupe de 16 bits (deux octets) et de '''quadruplet''' pour un groupe de 32 bits (quatre octets).
Précisons qu'en anglais, le terme ''byte'' n'est pas synonyme d'octet. En réalité, le terme ''octet'' marche aussi bien en français qu'en anglais. Quant au terme ''byte'', il désigne un concept complètement différent, que nous aborderons plus tard (c'est la plus petite unité de mémoire que le processeur peut adresser). Il a existé dans le passé des ordinateurs où le ''byte'' faisait 4, 7, 9, 16, voire 48 bits, par exemple. Il a même existé des ordinateurs où le ''byte'' faisait exactement 1 bit ! Mais sur presque tous les ordinateurs modernes, un ''byte'' fait effectivement 8 bits, ce qui fait que le terme ''byte'' est parfois utilisé en lieu et place d'''octet''. Mais c'est un abus de langage, attention aux confusions ! Dans ce cours, nous parlerons d'octet pour désigner un groupe de 8 bits, en réservant le terme ''byte'' pour sa véritable signification.
À l'intérieur d'un nombre, le '''bit de poids faible''' est celui qui est le plus à droite du nombre, alors que le '''bit de poids fort''' est celui non nul qui est placé le plus à gauche, comme illustré dans le schéma ci-dessous.
C'est le même principe que l'écriture des nombres en base décimale : le chiffre le plus significatif à gauche, et le moins significatif à droite (l'unité ou les décimales après la virgule).
[[File:Msb.svg|centre|vignette|upright=2|Bit de poids fort.]]
[[File:Lsb.svg|centre|vignette|upright=2|Bit de poids faible.]]
Cette terminologie s'applique aussi pour les bits à l'intérieur d'un octet, d'un ''nibble'', d'un doublet ou d'un quadruplet. Pour un nombre codés sur plusieurs octets, on peut aussi parler de l'octet de poids fort et de l'octet de poids faible, du doublet de poids fort ou de poids faible, etc.
====La traduction binaire→décimal====
Pour traduire un nombre binaire en décimal, il faut juste se rappeler que la position d'un bit indique par quelle puissance il faut le multiplier. Ainsi, le chiffre le plus à droite est le chiffre des unités : il doit être multiplié par 1 (<math>2^0</math>). Le chiffre situé immédiatement à gauche du chiffre des unités doit être multiplié par 2 (<math>2^1</math>). Le chiffre encore à gauche doit être multiplié par 4 (<math>2^2</math>), et ainsi de suite. Mathématiquement, on peut dire que le énième bit en partant de la droite doit être multiplié par <math>2^{n-1}</math>. Par exemple, la valeur du nombre noté 1011 en binaire est de <math>8 \times 1 + 4 \times 0 + 2 \times 1 + 1 \times 1 = 11</math>.
[[File:Value of digits in the Binary numeral system.svg|centre|vignette|upright=2|Valeur des chffres dans le système de numération binaire.]]
====La traduction décimal→binaire====
La traduction inverse, du décimal au binaire, demande d'effectuer des divisions successives par deux. Les divisions en question sont des divisions euclidiennes, avec un reste et un quotient. En lisant les restes des divisions dans un certain sens, on obtient le nombre en binaire. Voici comment il faut procéder, pour traduire le nombre 34 :
[[File:Exemple d'illustration de la méthode de conversion décimal vers binaire.png|centre|vignette|upright=2|Exemple d'illustration de la méthode de conversion décimal vers binaire.]]
====Quelques opérations en binaire====
Maintenant que l'on sait coder des nombres en binaire normal, il est utile de savoir comment faire quelques opérations usuelles en binaire. Nous utiliserons les acquis de cette section dans la suite du chapitre, bien que de manière assez marginale.
La première opération est assez spécifique au binaire. Il s'agit d'une opération qui inverse les bits d'un nombre : les 0 deviennent des 1 et réciproquement. Par exemple, le nombre 0001 1001 devient 1110 0110. Elle porte plusieurs noms : opération NOT, opération NON, complémentation, etc. Nous parlerons de '''complémentation''' ou d'opération NOT dans ce qui suit. Beaucoup d'ordinateurs gèrent cette opération, ils savent la faire en un seul calcul. Il faut dire que c'est une opération assez utile, bien que nous ne pouvons pas encore expliquer pourquoi.
[[File:Binary Addition Demonstration.svg|vignette|Exemple d'addition en binaire.]]
La seconde opération à aborder est l'addition. Elle se fait en binaire de la même manière qu'en décimal : Pour faire une addition en binaire, on additionne les chiffres/bits colonne par colonne, une éventuelle retenue est propagée à la colonne d'à côté. Sauf que l'on additionne des bits. Heureusement, la table d'addition est très simple en binaire :
* 0 + 0 = 0, retenue = 0 ;
* 0 + 1 = 1, retenue = 0 ;
* 1 + 0 = 1, retenue = 0 ;
* 1 + 1 = 0, retenue = 1.
La troisième opération est une variante de l'addition appelée l''''opération XOR''', notée <math>\oplus</math>. Il s'agit d'une addition dans laquelle on ne propage pas les retenues. L'addition des deux bits des opérandes se fait normalement, mais les retenues sont simplement oubliées, on n'en tient pas compte. Le résultat est que l'addition se résume à appliquer la table d'addition précédente :
* 0 <math>\oplus</math> 0 = 0 ;
* 0 <math>\oplus</math> 1 = 1 ;
* 1 <math>\oplus</math> 0 = 1 ;
* 1 <math>\oplus</math> 1 = 0.
Pour résumer, le résultat vaut 1 si les deux bits sont différents, 0 s'ils sont identiques. L'opération XOR sera utilisée rapidement dans le chapitre suivant, quand nous parlerons rapidement du mot de parité. Et elle sera beaucoup utilisée dans la suite du cours, nous en feront fortement usage. Pour le moment, mémorisez juste cette opération, elle n'a rien de compliqué.
Une dernière opération est l'opération de '''''population count'''''. Il s'agit ni plus ni moins que de compter le nombre de bits qui sont à 1 dans un nombre. Par exemple, pour le nombre 0110 0010 1101 1110, elle donne pour résultat 9. Elle est utilisée dans certaines applications, comme le calcul de certains codes correcteurs d'erreur, comme on le verra dans le chapitre suivant. Elle est supportée sur de nombreux ordinateurs, encore que cela dépende du processeur considéré. Il s'agit cependant d'une opération assez courante, supportée par les processeurs ARM, les processeurs x86 modernes (ceux qui gèrent le SSE), et quelques autres.
===L'hexadécimal===
L’hexadécimal est basé sur le même principe que le binaire, sauf qu'il utilise les 16 chiffres suivants :
{| class="wikitable"
|- style="color:#8080ffff; text-align:center;"
! Chiffre hexadécimal
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || A || B || C || D || E || F
|- style="text-align:center;"
! Nombre décimal correspondant
| 0 || 1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15
|- style="text-align:center;"
! Notation binaire
| 0000 || 0001 || 0010 || 0011 || 0100 || 0101 || 0110 || 0111 || 1000 || 1001 || 1010 || 1011 || 1100 || 1101 || 1110 || 1111
|}
Dans les textes, afin de différencier les nombres décimaux des nombres hexadécimaux, les nombres hexadécimaux sont suivis par un petit h, indiqué en indice. Si cette notation n'existait pas, des nombres comme 2546 seraient ambigus : on ne saurait pas dire sans autre indication s'ils sont écrits en décimal ou en hexadécimal. Avec la notation, on sait de suite que 2546 est en décimal et que 2546h est en hexadécimal.
Dans les codes sources des programmes, la notation diffère selon le langage de programmation.
Certains supportent le suffixe <code>h</code> pour les nombres hexadécimaux, d'autres utilisent un préfixe <code>0x</code> ou <code>0h</code>.
Pour convertir un nombre hexadécimal en décimal, il suffit de multiplier chaque chiffre par la puissance de 16 qui lui est attribuée. Là encore, la position d'un chiffre indique par quelle puissance celui-ci doit être multiplié : le chiffre le plus à droite est celui des unités, le second chiffre le plus à droite doit être multiplié par 16, le troisième chiffre en partant de la droite doit être multiplié par 256 (16 * 16) et ainsi de suite. La technique pour convertir un nombre décimal vers de l’hexadécimal est similaire à celle utilisée pour traduire un nombre du décimal vers le binaire. On retrouve une suite de divisions successives, mais cette fois-ci les divisions ne sont pas des divisions par 2 : ce sont des divisions par 16.
La conversion inverse, de l'hexadécimal vers le binaire est très simple, nettement plus simple que les autres conversions. Pour passer de l'hexadécimal au binaire, il suffit de traduire chaque chiffre en sa valeur binaire, celle indiquée dans le tableau au tout début du paragraphe nommé « Hexadécimal ». Une fois cela fait, il suffit de faire le remplacement. La traduction inverse est tout aussi simple : il suffit de grouper les bits du nombre par 4, en commençant par la droite (si un groupe est incomplet, on le remplit avec des zéros). Il suffit alors de remplacer le groupe de 4 bits par le chiffre hexadécimal qui correspond.
==Interlude propédeutique : la capacité d'un entier et les débordements d'entiers==
Dans la section précédente, nous avons vu comment coder des entiers positifs en binaire ou dans des représentations proches. La logique voudrait que l'on aborde ensuite le codage des entiers négatifs. Mais nous allons déroger à cette logique simple, pour des raisons pédagogiques. Nous allons faire un interlude qui introduira des notions utiles pour la suite du chapitre. De plus, ces concepts seront abordés de nombreuses fois dans ce wikilivre et l'introduire ici est de loin la solution idéale.
===Les ordinateurs manipulent des nombres codés sur un nombre fixe de bits===
Vous avez certainement déjà entendu parler de processeurs 32 ou 64 bits. Et si vous avez joué aux jeux vidéos durant votre jeunesse et êtes assez agé, vous avez entendu parler de consoles de jeu 8 bits, 16 bits, 32 bits, voire 64 bits (pour la Jaguar, et c'était un peu trompeur). Derrière cette appellation qu'on retrouvait autrefois comme argument commercial dans la presse se cache un concept simple. Tout ordinateur manipule des nombres entiers dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Une console 16 bits manipulait des entiers codés en binaire sur 16 bits, pas un de plus, pas un de moins. Pareil pour les anciens ordinateurs 32 bits, qui manipulaient des nombres entiers codés sur 32 bits.
Aujourd'hui, les ordinateurs modernes utilisent presque un nombre de bits qui est une puissance de 2 : 8, 16, 32, 64, 128, 256, voire 512 bits. Mais cette règle souffre évidemment d'exceptions. Aux tout débuts de l'informatique, certaines machines utilisaient 3, 7, 13, 17, 23, 36 et 48 bits ; mais elles sont aujourd'hui tombées en désuétude. De nos jours, il ne reste que les processeurs dédiés au traitement de signal audio, que l'on trouve dans les chaînes HIFI, les décodeurs TNT, les lecteurs DVD, etc. Ceux-ci utilisent des nombres entiers de 24 bits, car l'information audio est souvent codée par des nombres de 24 bits.
Anecdote amusante, il a existé des ordinateurs de 1 bit, qui sont capables de manipuler des nombres codés sur 1 bit, pas plus. Un exemple est le Motorola MC14500B, commercialisé de 1976.
====Le lien entre nombre de bits et valeurs codables====
Évidemment, on ne peut pas coder tous les entiers possibles et imaginables avec seulement 8 bits, ou 16 bits. Et il parait intuitif que l'on ait plus de valeurs codables sur 16 bits qu'avec 8 bits, par exemple. Plus le nombre de bits est important, plus on pourra coder de valeurs entières différentes. Mais combien de plus ? Par exemple, si je passe de 8 bits à 16 bits, est-ce que le nombre de valeurs que l'on peut coder double, quadruple, pentuple ? De même, combien de valeurs différentes on peut coder avec <math>n</math> bits. Par exemple, combien de nombres différents peut-on coder avec 4, 8 ou 16 bits ? La section précédente vous l'expliquer.
Avec <math>n</math> bits, on peut coder <math>2^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>2^n - 1</math>. N'oubliez pas cette formule : elle sera assez utile dans la suite de ce tutoriel. Pour exemple, on peut coder 16 valeurs avec 4 bits, qui vont de 0 à 15. De même, on peut coder 256 valeurs avec un octet, qui vont de 0 à 255. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de bits
! Nombre de valeurs codables
|-
! 4
| 16
|-
! 8
| 256
|-
! 16
| 65 536
|-
! 32
| 4 294 967 296
|-
! 64
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien de bits il faut pour coder une valeur quelconque, que nous noterons N. Pour cela, il faut utiliser la formule précédente, mais à l'envers. On cherche alors <math>n</math> tel que <math>N = 2^n</math>. L'opération qui donne <math>n</math> est appelée le logarithme, et plus précisément un logarithme en base 2, noté <math>\log_2{(X)}</math>. Le problème est que le résultat du logarithme ne tombe juste que si le nombre X est une puissance de 2. Si ce n'est pas le cas, le résultat est un nombre à virgule, ce qui n'a pas de sens pratique. Par exemple, la formule nous dit que pour coder le nombre 13, on a besoin de 3,70043971814 bits, ce qui est impossible. Pour que le résultat ait un sens, il faut arrondir à l'entier supérieur. Pour l'exemple précédent, les 3,70043971814 bits s'arrondissent en 4 bits.
====Le lien entre nombre de chiffres hexadécimaux et valeurs codables====
Maintenant, voyons combien de valeurs peut-on coder avec <math>n</math> chiffres hexadécimaux. La réponse n'est pas très différente de celle obtenue en binaire, si ce n'est qu'il faut remplacer le 2 par un 16 dans la formule précédente. Avec <math>n</math> chiffres hexadécimaux, on peut coder <math>16^n</math> valeurs différentes, dont le <math>0</math>, ce qui fait qu'on peut compter de <math>0</math> à <math>16^n - 1</math>. Le tableau ci-dessous donne quelques exemples communs.
{|class="wikitable"
|-
! Nombre de chiffres héxadécimaux
! Nombre de valeurs codables
|-
! 1 (4 bits)
| 16
|-
! 2 (8 bits)
| 256
|-
! 4 (16 bits)
| 65 536
|-
! 8 (32 bits)
| 4 294 967 296
|-
! 16 (64 bits)
| 18 446 744 073 709 551 615
|}
Inversement, on peut se demander combien faut-il de chiffres hexadécimaux pour coder une valeur quelconque en hexadécimal. La formule est là encore la même qu'en binaire, sauf qu'on remplace le 2 par un 16. Pour trouver le nombre de chiffres hexadécimaux pour encoder un nombre X, il faut calculer <math>\log_{16}{(X)}</math>. Notons que le logarithme utilisé est un logarithme en base 16, et non un logarithme en base 2, comme pour le binaire. Là encore, le résultat ne tombe juste que si le nombre X est une puissance de 16 et il faut arrondir à l'entier supérieur si ce n'est pas le cas.
Une propriété mathématique des logarithmes nous dit que l'on peut passer d'un logarithme en base X et à un logarithme en base Y avec une simple division, en utilisant la formule suivante :
: <math>\log_X{(N)} = {\log_{Y}{(N)} \over \log_Y(X)}</math>
Dans le cas qui nous intéresse, on a Y = 2 et X = 16, ce qui donne :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over \log_2(16)}</math>
Or, <math>\log_2(16)</math> est tout simplement égal à 4, car il faut 4 bits pour coder la valeur 16. On a donc :
: <math>\log_{16}{(N)} = {\log_{2}{(N)} \over 4}</math>
En clair, il faut quatre fois moins de chiffres hexadécimaux que de bits, ce qui est assez intuitif vu qu'il faut 4 bits pour coder un chiffre hexadécimal.
===Les débordements d'entier===
On vient de voir que tout ordinateur manipule des nombres dont le nombre de bits est toujours le même : on dit qu'ils sont de taille fixe. Et cela limite les valeurs qu'il peut encoder, qui sont comprises dans un intervalle bien précis. Mais que ce passe-t-il si jamais le résultat d'un calcul ne rentre pas dans cet intervalle ? Par exemple, pour du binaire normal, que faire si le résultat d'un calcul atteint ou dépasse <math>2^n</math> ? Dans ce cas, le résultat ne peut pas être représenté par l'ordinateur et il se produit ce qu'on appelle un '''débordement d'entier'''.
On peut imaginer d'autres codages pour lesquels les entiers ne commencent pas à zéro ou ne terminent pas à <math>2^n - 1</math>. On peut prendre le cas où l'ordinateur gère les nombres négatifs, par exemple. Dans le cas général, l'ordinateur peut coder les valeurs comprises dans un intervalle, qui va de la valeur la plus basse <math>N_\text{min}</math> à la valeur la plus grande <math>N_\text{max}</math>. Et encore une fois, si un résultat de calcul sort de cet intervalle, on fait face à un débordement d'entier.
La '''valeur haute de débordement''' désigne la première valeur qui est trop grande pour être représentée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 0 et 7, la valeur haute de débordement est égale à 8. Pour les nombres entiers, la valeur haute de débordement vaut <math>N_\text{max} + 1</math>, avec <math>N_\text{max}</math> la plus grande valeur codable par l'ordinateur.
On peut aussi définir la '''valeur basse de débordement''', qui est la première valeur trop petite pour être codée par l'ordinateur. Par exemple, pour un ordinateur qui peut coder tous les nombres entre 8 et 250, la valeur basse de débordement est égale à 7. Pour les nombres entiers, la valeur basse de débordement vaut <math>N_\text{min} - 1</math>, avec <math>N_\text{min}</math> la plus petite valeur codable par l'ordinateur.
===La gestion des débordements d'entiers===
Face à un débordement d'entier, l'ordinateur peut utiliser deux méthodes : l'arithmétique saturée ou l'arithmétique modulaire.
L''''arithmétique saturée''' consiste à arrondir le résultat pour prendre la plus grande ou la plus petite valeur. Si le résultat d'un calcul dépasse la valeur haute de débordement, le résultat est remplacé par le plus grand entier supporté par l'ordinateur. La même chose est possible quand le résultat est inférieur à la plus petite valeur possible, par exemple lors d'une soustraction : l'ordinateur arrondit au plus petit entier possible.
Pour donner un exemple, voici ce que cela donne avec des entiers codés sur 4 bits, qui codent des nombres de 0 à 15. Si je fais le calcul 8 + 9, le résultat normal vaut 17, ce qui ne rentre pas dans l'intervalle. Le résultat est alors arrondi à 15. Inversement, si je fais le calcul 8 - 9, le résultat sera de -1, ce qui ne rentre pas dans l'intervalle : le résultat est alors arrondi à 0.
[[File:Odometer rollover.jpg|vignette|Exemple de débordement d'entier sur un compteur mécanique quelconque. L'image montre clairement que le compteur revient à zéro une fois la valeur maximale dépassée.]]
L''''arithmétique modulaire''' est plus compliquée et c'est elle qui va nous intéresser dans ce qui suit. Pour simplifier, imaginons que l'on décompte à partir de zéro. Quand on arrive à la valeur haute de débordement, on recommence à compter à partir de zéro. L'arithmétique modulaire n'est pas si contre-intuitive et vous l'utilisez sans doute au quotidien. Après tout, c'est comme cela que l'on compte les heures, les minutes et les secondes. Quand on compte les minutes, on revient à 0 au bout de 60 minutes. Pareil pour les heures : on revient à zéro quand on arrive à 24 heures. Divers compteurs mécaniques fonctionnent sur le même principe et reviennent à zéro quand ils dépassent la plus grande valeur possible, l'image ci-contre en montrant un exemple.
Mathématiquement, l'arithmétique modulaire implique des divisions euclidiennes, celles qui donnent un quotient et un reste. Lors d'un débordement, le résultat s'obtient comme suit : on divise le nombre qui déborde par la valeur haute de débordement, et que l'on conserve le reste de la division. Au passage, l'opération qui consiste à faire une division et à garder le reste au lieu du quotient est appelée le ''modulo''. Prenons l'exemple où l'ordinateur peut coder tous les nombres entre 0 et 1023, soit une valeur haute de débordement de 1024. Pour coder le nombre 4563, on fait le calcul 4563 / 1024. On obtient : <math>4563 = 4 \times 1024 + 467</math>. Le reste de la division est de 467 et c'est lui qui sera utilisé pour coder la valeur de départ, 4563. Le nombre 4563 est donc codé par la valeur 467 dans un tel ordinateur en arithmétique modulaire. Au passage, la valeur haute de débordement est toujours codée par un zéro dans ce genre d'arithmétique.
Les ordinateurs utilisent le plus souvent une valeur haute de débordement de <math>2^n</math>, avec n le nombre de bits utilisé pour coder les nombres entiers positifs. En faisant cela, l'opération modulo devient très simple et revient à éliminer les bits de poids forts au-delà du énième bit. Par exemple, reprenons l'exemple d'un ordinateur qui code ses nombres sur 4 bits. Imaginons qu'il fasse le calcul <math>13 + 3</math>, soit 1101 + 0011 = 1 0000 en binaire. Le résultat entraîne un débordement d'entier et l'ordinateur ne conserve que les 4 bits de poids faible. Cela donne : 1101 + 0011 = 0000. Comme autre exemple l'addition 1111 + 0010 ne donnera pas 17 (1 0001), mais 1 (0001).
L'avantage est que les calculs sont beaucoup plus simples avec cette méthode qu'avec les autres. L'ordinateur a juste à ne pas calculer les bits de poids fort. Pas besoin de faire une division pour calculer un modulo, pas besoin de corriger le résultat pour faire de l'arithmétique saturée.
===Aparté : quelques valeurs particulières en binaire===
Plus haut, on a dit qu'avec n bits, on peut encoder toutes les valeurs allant de <math>0</math> à <math>2^n - 1</math>. Ce simple fait permet de déterminer quelle est la valeur de certains entiers à vue d’œil. Dans ce qui va suivre, nous allons poser quelques bases que nous réutiliserons dans la suite du chapitre. Il s'agit de quelques trivias qui sont cependant assez utiles.
Le premier trivia concerne la valeur maximale <math>2^n - 1</math> : elle est encodée par un nombre dont tous les bits sont à 1.
Le deuxième trivia concerne les nombres de la forme 000...000 1111 1111, à savoir des nombres dont les x bits de poids faible sont à 1 et tous les autres valent 0. Par définition, de tels nombres codent la plus grande valeur possible sur x bits, ce qui fait qu'ils valent <math>2^x - 1</math>.
Le troisième trivia concerne les nombres de la forme 11111...0000. En clair, des nombres où on a une suite consécutive de 1 dans les bits de poids fort et les x bits de poids faibles à 0. De tels nombres valent : <math>2^n - 2^x</math>. La preuve est assez simple : ils s'obtiennent en prenant la valeur maximale <math>2^n - 1</math>, et en soustrayant un nombre de la forme <math>2^x - 1</math>.
[[File:Valeurs particulières en binaire.png|centre|vignette|upright=2|Valeurs particulières en binaire]]
Si on utilise l'arithmétique modulaire, la valeur <math>2^n</math> n'est autre que la valeur de débordement haute pour les nombres stockés sur n bits, et se code comme le zéro (il faudrait 1 bit de plus pour stocker le 1 de poids fort de <math>2^n</math>). Les 1er et 3ème nombres évoqués dans les paragraphes précédents peuvent donc se passer de <math>2^n</math> dans leur expression :
* <math>- 1</math> est codé 1111111111111111...111 (n bits à 1)
* <math>- 2^x</math> est codé 11111...111000..000000 (x bits à 0 précédés de n-x bits à 1)
==Les nombres entiers négatifs==
<noinclude>[[File:Binary Representations.svg|vignette|Représentations signées, exemples sur 4 bits.]]</noinclude>
Passons maintenant aux entiers négatifs en binaire : comment représenter le signe moins ("-") avec des 0 et des 1 ? Eh bien, il existe plusieurs méthodes :
* la représentation en signe-valeur absolue ;
* la représentation en complément à un ;
* la représentation en complément à deux;
* la représentation par excès ;
* la représentation dans une base négative ;
* d'autres représentations encore moins utilisées que les autres.
===La représentation en signe-valeur absolue===
La solution la plus simple pour représenter un entier négatif consiste à coder sa valeur absolue en binaire, et rajouter un bit qui précise si c'est un entier positif ou un entier négatif. Par convention, ce bit de signe vaut 0 si le nombre est positif et 1 s'il est négatif. On parle alors de représentation en signe-valeur absolue, aussi appelée '''représentation en signe-magnitude'''.
Avec cette technique, il y autant de nombres positifs que négatifs. Mieux : pour chaque nombre représentable en représentation signe-valeur absolue, son inverse l'est aussi. Ce qui fait qu'avec cette méthode, le zéro est codé deux fois : on a un -0, et un +0. Cela pose des problèmes lorsqu'on demande à notre ordinateur d'effectuer des calculs ou des comparaisons avec zéro.
[[File:Codage sur 4 bits en signe-valeur absolue.png|centre|vignette|upright=2|Codage sur 4 bits en signe-valeur absolue]]
===La représentation en complément à un===
La représentation en '''complément à un''' peut être vue, en première approximation, comme une variante de la représentation en signe-magnitude. Et je dis bien : "en première approximation", car il y a beaucoup à dire sur la représentation en complément à un, mais nous verrons cela dans la section suivante sur le complément à deux. Conceptuellement, la représentation en signe-magnitude et celle du complément à un sont drastiquement différentes et sont basées sur des concepts mathématiques totalement différents. Mais elles ont des ressemblances de surface, qui font que faire la comparaison entre les deux est assez utile pédagogiquement parlant.
En complément à un, les nombres sont codés en utilisant un bit de signe qui indique si le nombre de positif ou négatif, couplé à une valeur absolue. Par contre, la valeur absolue est codée différemment en binaire. Pour les nombres positifs, la valeur absolue est codée en binaire normal, pas de changement comparé aux autres représentations. Mais pour les valeurs négatives, la valeur absolue est codée en binaire normal, puis tous les bits sont inversés : les 0 deviennent des 1 et réciproquement. En clair, on utilise une opération de complémentation pour les nombres négatifs.
Prenons un exemple avec un nombre codé sur 4 bits, avec un cinquième bit de signe. La valeur 5 est codée comme suit : 0 pour le bit de signe, 5 donne 0101 en binaire, le résultat est 0 0101. Pour la valeur -5, le bit de signe est 1, 5 donne 0101 en binaire, on inverse les bits ce qui donne 1010 : cela donne 11010. Notez qu'on peut passer d'un résultat à l'autre avec une opération de complémentation, à savoir en inversant les bits de l'autre et réciproquement.
Il s'agit là d'une propriété générale avec le complément à 1 : l'opposé d'un nombre, à savoir passer de sa valeur positive à sa valeur négative ou inversement, se calcule avec une opération NOT, une opération de complémentation (d'où son nom). L'avantage est que les ordinateurs gèrent naturellement d'opération de complémentation. Par contre, en signe-magnitude, inverser le bit de signe est une opération spécifique et ne sert qu'à ça. Il faut donc rajouter une opération en plus pour calculer l'opposé d'un nombre.
La représentation en complément à un garde les défauts de la représentation en signe-magnitude. Le zéro est codé deux fois, avec un zéro positif et un zéro négatif. La différence est que les valeurs négatives sont dans l'ordre inverse, il y a une symétrie un peu meilleure. Les deux zéros sont d'ailleurs totalement éloignés, ils correspondent aux valeurs extrêmes encodables : le zéro positif a tous ses bits à 0, le zéro négatif a tous ses bits à 1. Le résultat est que l'implémentation des comparaisons et de certains calculs est plus complexe qu'avec le signe-magnitude, mais de peu.
[[File:Codage sur 4 bits en complément à 1.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 1.]]
===La représentation par excès===
La représentation par excès consiste à ajouter un biais aux nombres à encoder afin de les encoder par un entier positif. Pour encoder tous les nombres compris entre -X et Y en représentation par excès, il suffit de prendre la valeur du nombre à encoder, et de lui ajouter un biais égal à X. Ainsi, la valeur -X sera encodée par zéro, et toutes les autres valeurs le seront par un entier positif, le zéro sera encodé par X, 1, par X+1, etc. Par exemple, prenons des nombres compris entre -127 et 128. On va devoir ajouter un biais égal à 127, ce qui donne :
{|class="wikitable"
|-
!Valeur avant encodage!!Valeur après encodage
|-
||-127||0
|-
||-126||1
|-
||-125||2
|-
||…||…
|-
||0||127
|-
||…||…
|-
||127||254
|-
||128||255
|}
===La représentation en complément à deux===
La représentation en '''complément à deux''' est basée sur les mêmes mathématiques que le complément à un, mais fonctionne très différemment en pratique.
Si on regarde de loin, son principe est assez différent : il n'y a pas de bit de signe ni quoi que ce soit d'autre. À la place, un nombre en complément à deux est encodé comme en binaire normal, à un point près : le bit de poids fort est soustrait, et non additionné aux autres. Il a une valeur négative : on soustrait la puissance de deux associée s'il vaut 1, on ne la tient pas en compte s'il vaut 0.
Par exemple, la valeur du nombre noté 111001 en complément à deux s'obtient comme suit :
{|class="wikitable" style="margin:auto;"
|-
! -32 !! 16 !! 8 !! 4 !! 2 !! 1
|-
| 1 || 1 || 1 || 0 || 0 || 1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(8×1)+(4×0)+(2×1)+(1×1) = −32+16+8+1 = -7.
: ''Avec le complément à deux, comme avec le complément à un, le bit de poids fort vaut 0 pour les nombres positifs et 1 pour les négatifs.''
L'avantage de cette représentation est qu'elle n'a pas de double zéro : le zéro n'est encodé que par une seule valeur. Par contre, la valeur autrefois prise par le zéro négatif est réutilisée pour encoder une valeur négative. La conséquence est que l'on a un nombre négatif en plus d'encodé, il n'y a plus le même nombre de valeurs strictement positives et de valeurs négatives encodées. Le nombre négatif en question est appelé le '''nombre le plus négatif''', ce nom trahit le fait que c'est celui qui a la plus petite valeur (la plus grande valeur absolue). Il est impossible de coder l'entier positif associé, sa valeur absolue.
[[File:Codage sur 4 bits en complément à 2.png|centre|vignette|upright=2|Codage sur 4 bits en complément à 2.]]
====Le calcul du complément à deux : première méthode====
Les représentations en complément à un et en complément à deux sont basées sur le même principe mathématique. Leur idée est de remplacer chaque nombre négatif par un nombre positif équivalent, appelé le ''complément''. Par équivalent, on veut dire que tout calcul donne le même résultat si on remplace un nombre négatif par son complément (idem avec un nombre positif, son complément aura un signe inverse). Et pour faire cela, elles se basent sur les débordements d'entier pour fonctionner, et plus précisément sur l'arithmétique modulaire abordée plus haut.
Si on fait les calculs avec le complément, les résultats du calcul entraînent un débordement d'entier, qui sera résolu par un '''modulo''' : l'ordinateur ne conserve que les bits de poids faible du résultat, les autres bits sont oubliés. Par exemple, prenons l'addition 15 + 2, 1111 + 0010 en binaire : le résultat ne sera pas 17 (10001), vu qu'on n'a pas assez de bits pour encoder le résultat, mais 1 (0001). Et le résultat après modulo sera identique au résultat qu'on aurait obtenu avec le nombre négatif sans modulo. En clair, c'est la gestion des débordements qui permet de corriger le résultat de manière à ce que l'opération avec le complément donne le même résultat qu'avec le nombre négatif voulu. Ainsi, on peut coder un nombre négatif en utilisant son complément positif.
: Cela ressemble beaucoup à la méthode de soustraction basée sur un complément à 9 pour ceux qui connaissent, sauf que c'est une version binaire qui nous intéresse ici.
Prenons un exemple, qui permettra d'introduire la suite. Encore une fois, on utilise un codage sur 4 bits dont la valeur haute de débordement est de 16. Prenons l'addition de 13 + 3 = 16. Avec l'arithmétique modulaire, 16 est équivalent à 0, ce qui donne : 13 + 3 = 0 ! On peut aussi reformuler en disant que 13 = -3, ou encore que 3 = -13. Dit autrement, 3 est le complément de -13 pour ce codage. Et ne croyez pas que ça marche uniquement dans cet exemple : cela se généralise assez rapidement à tout nombre négatif.
Prenons un nombre N dont un veut calculer le complément à deux K. Dans le cas général, on a :
: <math>N + K = 2^n</math>, vu que <math>2^n = 0</math> en utilisant le modulo.
En réorganisant très légèrement les termes pour isoler K, on a :
: <math>K = 2^n - N</math>
La formule précédent permet de calculer la complément à deux assez simplement, en faisant le calcul à la main.
Avant de poursuivre, prenons un exemple très intéressant : le cas où N = -1. Son complément à deux vaut donc :
: <math>K = 2^n - 1</math>
Le terme <math>2^n - 1</math> devrait vous rappeler quelque chose : il s'agit d'un nombre dont tous les bits sont à 1. En clair, le complément à deux de -1 est un nombre de la forme 1111...111. Et cela vaut quel que soit le nombre de bits n. La représentation de -1 est similaire, peu importe que l'on utilise des nombres de 16 bits, 32 bits, 64 bits, etc.
Voyons maintenant le cas des puissance de deux, par exemple 2, 4, 8, 16, etc. Leur complément à deux vaut :
: <math>K = 2^n - 2^x</math>
Nous avions vu précédemment dans le chapitre que ces nombres sont de la forme 11111...0000. En clair, des nombres dont les x bits de poids faible sont à 0, et tous les autres bits sont à 1. Par exemple, le complément à deux de -2 est un nombre dont les bits sont à 1, sauf le bit de poids faible. De même, le complément à deux de -4 a tous ses bits à 1, sauf les deux bits de poids faible. Et le complément à deux de -8 a tous ses bits à 1, sauf les trois bits de poids faible. Pour résumer, tout nombre de la forme <math>- 2^x</math> a tous ses bits à 1, sauf les x bits de poids faible qui sont à 0.
{|class="wikitable"
|+ Exemple avec des nombres de 8 bits
|-
! -1
| 1111 1111
|-
! -2
| 1111 1110
|-
! -4
| 1111 1100
|-
! -8
| 1111 1000
|-
! -16
| 1111 0000
|-
! -32
| 1110 0000
|-
! -64
| 1100 0000
|-
! -128
| 1000 0000
|-
! 0 / - 256
| 0000 0000
|}
====Le calcul du complément à deux : seconde méthode====
Il existe une seconde méthode pour calculer le complément à deux d'un nombre, la voici. Pour les nombres positifs, encodez-les comme en binaire normale. Pour les nombres négatifs, faites pareil, puis inversez tous les bits, avant d'ajouter un. La procédure est identique à celle du complément à un, sauf que l'on incrémente le résultat final. Et ce n'est pas une coïncidence, comme nous allons le voir immédiatement.
Pour comprendre pourquoi la méthode marche, repartons de la formule précédente <math>K = 2^n - N</math>. Elle peut se reformuler comme suit :
: <math>K = (2^n - 1 + 1) - N</math>
: <math>K = (2^n - 1) - N + 1</math>
La valeur <math>(2^n - 1)</math> est par définition un nombre dont tous les bits sont à 1. À cette valeur, on soustrait un nombre <math>|N|</math> dont certains bits sont à 1 et d'autres à 0. En clair, pour chaque colonne, on a deux possibilités : soit on doit faire la soustraction <math>1-1</math>, soit la soustraction <math>1-0</math>. Or, les règles de l'arithmétique binaire disent que <math>1-1=0</math> et <math>1-0=1</math>. En regardant attentivement, on se rend compte que le bit du résultat est l'inverse du bit de départ. De plus, les deux cas ne donnent pas de retenue : le calcul pour chaque bit n'influence pas les bits voisins.
Le terme <math>(2^n - 1) - N</math> est donc le complément à un du nombre N, un nombre égal à sa représentation en binaire dont tous les bits sont inversés. Notons <math>\overline{|N|}</math> le nombre formé en inversant tous les bits de N. On a alors :
: <math>K = \overline{N} + 1</math>
En clair, le complément à deux s'obtient en prenant le complément à un et en ajoutant 1. Dit autrement, il faut prendre le nombre N, en inverser tous les bits et ajouter 1.
Une autre manière équivalente consiste à faire le calcul suivant :
: <math>K = \overline{N - 1}</math>
On prend le nombre dont on veut le complément, on soustrait 1 et on inverse les bits.
: Notons que tout ce qui a été dit plus haut marche aussi pour le complément à un, avec cependant une petite différence : la valeur haute de débordement n'est pas la même, ce qui change les calculs. Pour des nombres codés sur <math>n</math> bits, la valeur haute de débordement est égale à <math>2^n</math> en complément à deux, alors qu'elle est de <math>2^n - 1</math> en complément à un. De ce fait, la gestion des débordements est plus simple en complément à deux.
====L'extension de signe====
Dans les ordinateurs, tous les nombres sont codés sur un nombre fixé et constant de bits. Ainsi, les circuits d'un ordinateur ne peuvent manipuler que des nombres de 4, 8, 12, 16, 32, 48, 64 bits, suivant la machine. Si l'on veut utiliser un entier codé sur 16 bits et que l'ordinateur ne peut manipuler que des nombres de 32 bits, il faut bien trouver un moyen de convertir le nombre de 16 bits en un nombre de 32 bits, sans changer sa valeur et en conservant son signe. Cette conversion d'un entier en un entier plus grand, qui conserve valeur et signe s'appelle l''''extension de signe'''.
L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
L'explication plus simple tient dans la manière de coder le bit de poids fort. Prenons l'exemple de la conversion d'un entier de 5 bits en un entier de 8bits. Les 4 bits de poids faible ont un poids positif (on les additionne), alors que le bit de poids fort a un poids négatif. Le nombre encodé vaut : <math>\text{Bit de poids fort} \times -16 + \text{Valeur encodée sur les 4 bits de poids faible}</math>. La valeur encodée sur 4 bits reste la même après extension de signe, car les poids des bits de poids faible ne changent pas. Par contre, le bit de poids fort change. Sur 8 bits, la valeur -16 est encodée par un nombre de la forme 1111 0000. En remplaçant le bit de poids fort par sa valeur calculée sur plus de bits, on remarque que les bits de poids fort ont été remplacés par des 1.
===La représentation négabinaire===
Enfin, il existe une dernière méthode, assez simple à comprendre, appelée '''représentation négabinaire'''. Dans cette méthode, les nombres sont codés non en base 2, mais en base -2. Oui, vous avez bien lu : la base est un nombre négatif. Dans les faits, la base -2 est similaire à la base 2 : il y a toujours deux chiffres (0 et 1), et la position dans un chiffre indique toujours par quelle puissance de 2 il faut multiplier, sauf qu'il faudra ajouter un signe moins une fois sur 2. Concrètement, les puissances de -2 sont les suivantes : 1, -2, 4, -8, 16, -32, 64, etc. En effet, un nombre négatif multiplié par un nombre négatif donne un nombre positif, ce qui fait qu'une puissance sur deux est négative, alors que les autres sont positives. Ainsi, on peut représenter des nombres négatifs, mais aussi des nombres positifs dans une puissance négative.
Par exemple, la valeur du nombre noté 11011 en base -2 s'obtient comme suit :
{|class="wikitable"
|-
!…
!-32
!16
!-8
!4
!-2
!1
|-
|…
|1
|1
|1
|0
|1
|1
|}
Sa valeur est ainsi de (−32×1)+(16×1)+(−8×1)+(4×0)+(−2×1)+(1×1)=−32+16−8−2+1=−25.
==Les nombres à virgule fixe==
On sait donc comment sont stockés nos nombres entiers dans un ordinateur. Néanmoins, les nombres entiers ne sont pas les seuls nombres que l'on utilise au quotidien : il nous arrive d'en utiliser à virgule. Notre ordinateur n'est pas en reste : il est lui aussi capable de manipuler de tels nombres. Dans les grandes lignes, il peut utiliser deux méthodes pour coder des nombres à virgule en binaire : La virgule fixe et la virgule flottante.
===La virgule fixe : des entiers cachés===
La méthode de la virgule fixe consiste à émuler les nombres à virgule à partir de nombres entiers. Un nombre à virgule fixe est codé par un nombre entier proportionnel au nombre à virgule fixe. Pour obtenir la valeur de notre nombre à virgule fixe, il suffit de diviser l'entier servant à le représenter par le facteur de proportionnalité. Par exemple, pour coder 1,23 en virgule fixe, on peut choisir comme « facteur de conversion » 1000, ce qui donne l'entier 1230.
Généralement, les informaticiens utilisent une puissance de deux comme facteur de conversion, pour simplifier les calculs. En faisant cela, on peut écrire les nombres en binaire et les traduire en décimal facilement. Pour l'exemple, cela permet d'écrire des nombres à virgule en binaire comme ceci : 1011101,1011001. Et ces nombres peuvent se traduire en décimal avec la même méthode que des nombres entier, modulo une petite différence. Comme pour les chiffres situés à gauche de la virgule, chaque bit situé à droite de la virgule doit être multiplié par la puissance de deux adéquate. La différence, c'est que les chiffres situés à droite de la virgule sont multipliés par une puissance négative de deux, c'est à dire par <math>1 \over 2</math>, <math>1 \over 4</math>, <math>1 \over 8</math>, <math>1 \over 16</math>, <math>1 \over 32</math>, ...
Cette méthode est assez peu utilisée de nos jours, quoiqu'elle puisse avoir quelques rares applications relativement connue. Un bon exemple est celui des banques : les sommes d'argent déposées sur les comptes ou transférées sont codés en virgule fixe. Les sommes manipulées par les ordinateurs ne sont pas exprimées en euros, mais en centimes d'euros. Et c'est une forme de codage en virgule fixe dont le facteur de conversion est égal à 100. La raison de ce choix est que les autres méthodes de codage des nombres à virgule peuvent donner des résultats imprécis : il se peut que les résultats doivent être tronqués ou arrondis, suivant les opérandes. Cela n'arrive jamais en virgule fixe, du moins quand on se limite aux additions et soustractions.
===Les nombres à virgule non-représentables en binaire===
Quel que soit la façon de représenter les nombres à virgules, il existe des nombres qui ne peuvent être représentés de manière exacte, à savoir avec un nombre fini de décimales après la virgule. En soi, ce n'est pas spécifique au binaire, on a la même chose en décimal. Par exemple, la fraction 1/3 en décimal s'écrit 0.3333333..., avec une infinité de 3. La même chose existe en binaire, mais pour des nombres différents.
Déjà, évacuons le cas des nombres irrationnels, à savoir les nombres qui ne peuvent pas s'écrire sous la forme d'une fraction, comme <math>pi</math> ou <math>e</math>. Ils ont une infinité de décimales que ce soit en binaire, en décimal, en hexadécimal, ou autre. Ils ne sont pas représentables avec un nombre fini de décimales quelle que soit la base utilisée. Concentrons-nous sur des nombres qui ne sont pas dans ce cas, et qui ont un nombre fini ou infini de décimales.
Le passage de la base 10 à la base 2 change le nombre de décimales, et peut faire passer d'un nombre fini de décimales à un nombre infini. Par exemple, <math>\frac{1}{5} = 0.2</math> est représenté en binaire avec une séquence infinie de bits : <math>0.001100110011...</math>. Les nombres étant en base binaire et représenté avec un nombre limité de bits, il existe certains nombres décimaux triviaux qui ne sont pas représentables avec un nombre fini de décimales.
Et la réciproque n'est pas vraie : tout nombre binaire avec un nombre fini de décimales en binaire est représentable avec un nombre fini de décimales en base 10. Ceci est lié à la décomposition en facteur premier des bases utilisées :
* <math>2 = 2^1</math>
* <math>10 = 2^1 \times 5^1</math>
Le nombre 10 possède tous les facteurs premiers de 2, mais 2 n'a pas de 5 dans sa décomposition.
==Les nombres flottants==
Les nombres à virgule fixe ont aujourd'hui été remplacés par les '''nombres à virgule flottante''', où le nombre de chiffres après la virgule est variable. Le codage d'un nombre flottant est basée sur son ''écriture scientifique''. Pour rappel, en décimal, l’écriture scientifique d'un nombre consiste à écrire celui-ci comme un produit entre un nombre <math>a</math> et une puissance de 10. Ce qui donne :
: <math>a \times 10^e</math>, avec <math>a \in [1, 10 [</math>
Le nombre <math>a</math> est appelé le ''significande'' et il est compris entre 1 (inclus) et 10 (exclu). Cette contrainte garantit que l'écriture scientifique d'un nombre est unique, qu'il n'y a qu'une seule façon d'écrire un nombre en notation scientifique. Pour cela, on impose le nombre de chiffre à gauche de la virgule et le plus simple est que celui-ci soit égal à 1. Mais il faut aussi que celui-ci ne soit pas nul. En effet, si on autorise de mettre un 0 à gauche de la virgule, il y a plusieurs manières équivalentes d'écrire un nombre. Ces deux contraintes font que le significande doit être égal ou plus grand que 1, mais strictement inférieur à 10. Par contre, on peut mettre autant de décimales que l'on veut.
En binaire, c'est la même chose, mais avec une puissance de deux. Cela implique de modifier la puissance utilisée : au lieu d'utiliser une puissance de 10, on utilise une puissance de 2.
: <math>a \times 2^e</math>, avec <math>a \in [1, 2 [</math>
Le significande est aussi altéré, au même titre que la puissance, même si les contraintes sont similaires à celles en base 10. En effet, le nombre <math>a</math> ne possède toujours qu'un seul chiffre à gauche de la virgule, comme en base 10. Vu que seuls deux chiffres sont possibles (0 et 1) en binaire, on s'attend à ce que le chiffre situé à gauche de la virgule soit un zéro ou un 1. Mais rappelons que le chiffre à gauche doit être non-nul, pour les mêmes raisons qu'en décimal. En clair, le significande a forcément un bit à 1 à gauche de la virgule. Pour récapituler, l'écriture scientifique binaire d'un nombre consiste à écrire celui-ci sous la forme :
: <math>1,m \times 2^e</math>, avec <math>m \in [0, 1 [</math>
La partie fractionnaire du nombre <math>a</math>, qu'on appelle la '''mantisse'''.
[[File:Mantissa.svg|centre|vignette|Écriture scientifique (anglais).]]
===Traduire un nombre en écriture scientifique binaire===
Pour déterminer l'écriture scientifique en binaire d'un nombre quelconque, la procédure dépend de la valeur du nombre en question. Tout dépend s'il est dans l'intervalle <math>[1, 2 [</math>, au-delà de 2 ou en-dessous de 1.
* Pour un nombre entre 1 (inclus) et 2 (exclu), il suffit de le traduire en binaire. Son exposant est 0.
* Pour un nombre au-delà de 2, il faut le diviser par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant est alors le nombre de fois qu'il a fallu diviser par 2.
* Pour un nombre plus petit que 1, il faut le multiplier par 2 autant de fois qu'il faut pour qu'il rentre dans l’intervalle <math>a \in [1, 2 [</math>. L'exposant se calcule en prenant le nombre de fois qu'il a fallu multiplier par 2, et en prenant l'opposé (en mettant un signe - devant le résultat).
===Le codage des nombres flottants et la norme IEEE 754===
Pour coder cette écriture scientifique avec des nombres, l'idée la plus simple est d'utiliser trois nombres, pour coder respectivement la mantisse, l'exposant et un bit de signe. Coder la mantisse implique que le bit à gauche de la virgule vaut toujours 1, mais nous verrons qu'il y a quelques rares exceptions à cette règle.
Quelques nombres flottants spécialisés, les dénormaux, ne sont pas codés en respectant les règles pour le significande et ont un 0 à gauche de la virgule. Un bon exemple est tout simplement la valeur zéro, que l'on peut coder en virgule flottante, mais seulement en passant outre les règles sur le significande.
Toujours est-il que le bit à gauche de la virgule n'est pas codé, que ce soit pour les flottants normaux ou les fameux dénormaux qui font exception. On verra que ce bit peut se déduire en fonction de l'exposant utilisé pour encoder le nombre à virgule, ce qui lui vaut le nom de '''bit implicite'''. L'exposant peut être aussi bien positif que négatif (pour permettre de coder des nombres très petits), et est encodé en représentation par excès sur n bits avec un biais égal à <math>2^{n-1} - 1</math>.
[[File:IEEE754 Format General.png|centre|vignette|upright=2|IEEE754 Format Général]]
Le standard pour le codage des nombres à virgule flottante est la '''norme IEEE 754'''. Cette norme va (entre autres) définir quatre types de flottants différents, qui pourront stocker plus ou moins de valeurs différentes.
{|class="wikitable"
|-
!Classe de nombre flottant
!Nombre de bits utilisés pour coder un flottant
!Nombre de bits de l'exposant
!Nombre de bits pour la mantisse
!Décalage
|-
!Simple précision
| 32
| 8
| 23
| 127
|-
!Double précision
| 64
| 11
| 52
| 1023
|-
!Double précision étendue
| 80 ou plus
| 15 ou plus
| 64 ou plus
| 16383 ou plus
|}
Voici à peu-près comment les nombres flottants sont répartis sur une ligne numérique. Vous remarquerez que les flottants sont de plus en plus éloignés au fur et à mesure qu'on s'éloigne de zéro. La raison est l'encodage avec un exposant et une mantisse. L'écart entre deux flottants reste stable pour un même exposant, mais il y a une cassure quand on passe d'un exposant au suivant.
[[File:FloatingPointPrecisionAugmented.png|centre|vignette|upright=3|Floating Point Precision.]]
IEEE754 impose aussi le support de certains nombres flottants spéciaux qui servent notamment à stocker des valeurs comme l'infini. Commençons notre revue des flottants spéciaux par les '''dénormaux''', aussi appelés flottants dénormalisés. Ces flottants ont une particularité : leur bit implicite vaut 0. Ces dénormaux sont des nombres flottants où l'exposant est le plus petit possible. Le '''zéro''' est un dénormal particulier dont la mantisse est nulle. Au fait, remarquez que le zéro est codé deux fois à cause du bit de signe : on se retrouve avec un -0 et un +0.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur minimale (0 en binaire) || Mantisse différente de zéro (dénormal strict) ou égale à zéro (zéro)
|}
Fait étrange, la norme IEEE754 permet de représenter '''l'infini''', aussi bien en positif qu'en négatif. Celui-ci est codé en mettant l'exposant à sa valeur maximale et la mantisse à zéro. Et le pire, c'est qu'on peut effectuer des calculs sur ces flottants infinis. Mais cela a peu d'utilité.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse égale à zéro
|}
Mais malheureusement, l'invention des flottants infinis n'a pas réglé tous les problèmes. Par exemple, quel est le résultat de <math>\infty - \infty</math> ? Ou encore <math>0 - \infty</math> ? Autant prévenir tout de suite : mathématiquement, on ne peut pas savoir quel est le résultat de ces opérations. Pour pouvoir résoudre ces calculs, il a fallu inventer un nombre flottant qui signifie « je ne sais pas quel est le résultat de ton calcul pourri ». Ce nombre, c'est '''NaN'''. NaN est l'abréviation de ''Not A Number'', ce qui signifie : n'est pas un nombre. Ce NaN a un exposant dont la valeur est maximale, mais une mantisse différente de zéro. Pour être plus précis, il existe différents types de NaN, qui diffèrent par la valeur de leur mantisse, ainsi que par les effets qu'ils peuvent avoir. Malgré son nom explicite, on peut faire des opérations avec NaN, mais cela ne sert pas vraiment à grand chose : une opération arithmétique appliquée avec un NaN aura un résultat toujours égal à NaN.
{|class="wikitable"
|-
! scope="col"| Bit de signe || Exposant || Mantisse
|-
| 0 ou 1 || Valeur maximale || Mantisse différente de zéro
|}
===Les arrondis et exceptions===
La norme impose aussi une gestion des arrondis ou erreurs, qui arrivent lors de calculs particuliers. En voici la liste :
{|class="wikitable"
|-
!Nom de l’exception
!Description
|-
!Invalid operation
|Opération qui produit un NAN. Elle est levée dans le cas de calculs ayant un résultat qui est un nombre complexe, ou quand le calcul est une forme indéterminée. Pour ceux qui ne savent pas ce que sont les formes indéterminées, voici en exclusivité la liste des calculs qui retournent NaN : <math>\infty - \infty</math>, <math>0 \times \infty</math>, <math>0^0</math>, <math>1^{\infty}</math>, <math>\infty^0</math>.
|-
!Overflow
|Résultat trop grand pour être stocké dans un flottant. Le plus souvent, on traite l'erreur en arrondissant le résultat en vue de la taille de la mantisse;
|-
!Underflow
|Pareil que le précédent, mais avec un résultat trop petit. Le plus souvent, on traite l'erreur en arrondissant le résultat vers 0.
|-
!Division par zéro
|Le nom parle de lui-même. La réponse la plus courante est de répondre + ou - l'infini.
|-
!'''Inexact'''
|Le résultat ne peut être représenté par un flottant et on doit l'arrondir.
|}
La gestion des arrondis pose souvent problème. Pour donner un exemple, on va prendre le nombre 0,1. En binaire, ce nombre s'écrit comme ceci : 0,1100110011001100... et ainsi de suite jusqu'à l'infini. Notre nombre utilise une infinité de décimales. Bien évidemment, on ne peut pas utiliser une infinité de bits pour stocker notre nombre et on doit impérativement l'arrondir. Comme vous le voyez avec la dernière exception, le codage des nombres flottants peut parfois poser problème : dans un ordinateur, il se peut qu'une opération sur deux nombres flottants donne un résultat qui ne peut être codé par un flottant. On est alors obligé d'arrondir ou de tronquer le résultat de façon à le faire rentrer dans un flottant. Pour éviter que des ordinateurs différents utilisent des méthodes d'arrondis différentes, on a décidé de normaliser les calculs sur les nombres flottants et les méthodes d'arrondis. Pour cela, la norme impose le support de quatre modes d'arrondis :
* Arrondir vers + l'infini ;
* vers - l'infini ;
* vers zéro ;
* vers le nombre flottant le plus proche.
===Les nombres flottants logarithmiques===
Les '''nombres flottants logarithmiques''' sont une spécialisation des nombres flottants IEEE754, ou tout du moins une spécialisation des flottants écrits en écriture scientifique. Un nombre logarithmique est donc composé d'un bit de signe et d'un exposant, sans mantisse. La mantisse est totalement implicite : tous les flottants logarithmiques ont la même mantisse, qui vaut 1.
Pour résumer, il ne reste que l'exposant, qui est tout simplement le logarithme en base 2 du nombre encodé, d'où le nom de codage flottant logarithmique donné à cette méthode. Attention toutefois : l'exposant est ici un nombre fractionnaire, codé en virgule fixe. Le choix d'un exposant fractionnaire permet de représenter pas mal de nombres de taille diverses.
{|class="wikitable"
|-
!
! Bit de signe
! Exposant
|-
! Représentation binaire
| 0
| 01110010101111
|-
! Représentation décimale
| +
| 1040,13245464
|}
L'utilité de cette représentation est de simplifier certains calculs, comme les multiplications, divisions, puissances, etc. En effet, les mathématiques nous disent que le logarithme d'un produit est égal à la somme des logarithmes : <math>\ln{(a \times b)} = \ln{a} + \ln{b}</math>. Or, il se trouve que les ordinateurs sont plus rapides pour faire des additions/soustractions que pour faire des multiplications/divisions. Donc, la représentation logarithmique permet de remplacer les multiplications/divisions par des additions/soustractions, plus simples et plus rapides pour l'ordinateur.
Évidemment, les applications des flottants logarithmiques sont rares, limitées à quelques situations bien précises (traitement d'image, calcul scientifique spécifiques).
==Les encodages binaires alternatifs==
Outre le binaire que nous venons de voir, il existe d'autres manières de coder des nombres avec des bits. Et nous allons les aborder dans cette section. Parmi celle-ci, nous parlerons du code Gray, de la représentation ''one hot'', du unaire et du ternaire. Nous en parlons car elles seront utiles dans la suite du cours, bien que de manière assez limitée. Autant nous passerons notre temps à parler du binaire normal, autant les représentations que nous allons voir sont aujourd'hui utilisées dans des cas assez spécifiques. Et elles sont plus courantes que vous ne le pensez.
===Le code Gray===
Le '''code Gray''' est un encodage binaire qui a une particularité très intéressante : deux nombres consécutifs n'ont qu'un seul bit de différence. Pour exemple, voici ce que donne le codage des 8 premiers entiers sur 3 bits :
{|class="wikitable"
! Décimal
! Binaire naturel
! Codage Gray
|-
|0||000||000
|-
|1||001||001
|-
|2||010||011
|-
|3||011||010
|-
|4||100||110
|-
|5||101||111
|-
|6||110||101
|-
|7||111||100
|}
Les utilisations du code Gray sont assez nombreuses, bien qu'on n'en croise pas tous les jours. Un exemple : le code Gray est très utile dans certains circuits appelés les compteurs, qui mémorisent un nombre et l'incrémentent (+1) ou le décrémentent (-1) suivant les besoins de l'utilisateur. Il est aussi utilisé dans des scénarios difficiles à expliquer ici (des codes correcteurs d'erreur ou des histoires de passages de domaines d'horloge). Mais le point important est que ce code sera absolument nécessaire dans quelques chapitres, quand nous parlerons des tables de Karnaugh, un concept important pour la conception de circuits électroniques. Ne passez pas à côté de cette section.
Pour construire ce code Gray, on peut procéder en suivant plusieurs méthodes, les deux plus connues étant la méthode du miroir et la méthode de l'inversion.
[[File:Binary-reflected Gray code construction.svg|vignette|Construction d'un code gray par la méthode du miroir.]]
La méthode du miroir est relativement simple. Pour connaître le code Gray des nombres codés sur n bits, il faut :
* partir du code Gray sur n-1 bits ;
* symétriser verticalement les nombres déjà obtenus (comme une réflexion dans un miroir) ;
* rajouter un 0 au début des anciens nombres, et un 1 au début des nouveaux nombres.
Il suffit de connaître le code Gray sur 1 bit pour appliquer la méthode : 0 est codé par le bit 0 et 1 par le bit 1.
Une autre méthode pour construire la suite des nombres en code Gray sur n bits est la méthode de l'inversion. Celle-ci permet de connaître le codage du nombre n à partir du codage du nombre n-1, comme la méthode du dessus. On part du nombre 0, systématiquement codé avec uniquement des zéros. Par la suite, on décide quel est le bit à inverser pour obtenir le nombre suivant, avec la règle suivante :
* si le nombre de 1 est pair, il faut inverser le dernier chiffre.
* si le nombre de 1 est impair, il faut localiser le 1 le plus à droite et inverser le chiffre situé à sa gauche.
Pour vous entraîner, essayez par vous-même avec 2, 3, voire 5.
===Les représentations ''one-hot'' et unaire===
La représentation ''one-hot'' et la représentation unaire sont deux représentations assez liées, mais légèrement différentes.
La '''représentation unaire''' est la représentation en base 1. Avec elle, le zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0011, le trois avec 000...0111, etc. Pour résumer, le nombre N est encodé en mettant les N bits de poids faible à 1, à l'exception du zéro qui est encodé...par un zéro.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! Unaire
|-
| 0 || 000 || 0000 0000
|-
| 1 || 001 || 0000 0001
|-
| 2 || 010 || 0000 0011
|-
| 3 || 011 || 0000 0111
|-
| 4 || 100 || 0000 1111
|-
| 5 || 101 || 0001 1111
|-
| 6 || 110 || 0011 1111
|-
| 7 || 111 || 0111 1111
|-
| 8 || 1000 || 1111 1111
|}
Pour convertir un nombre codé en unaire vers le binaire, il suffit de compter le nombre de 1. C'est ni plus ni moins ce que fait l'opération dite de ''population count'' dont nous avons parlé plus haut. La représentation unaire permet d'encoder N+1 valeurs sur N bits, car le zéro est encodé à part. Une valeur pour le zéro, plus une par bit.
Avec la '''représentation ''one-hot''''', un nombre est codé sur N bits, dont un seul bit est à 1. La position du 1 dans le nombre indique sa valeur. Précisément, la valeur encodée est égale au poids du bit à 1. En faisant cela, le zéro est codé en mettant le bit de poids faible à 1, la valeur 1 est codée avec les bits 00...00010, la valeur 2 est codée par les bits 00...000100, etc.
{| class="wikitable" border="1"
|-
! Décimal !! Binaire !! ''One-hot''
|-
| 0 || 000 || 00000001
|-
| 1 || 001 || 00000010
|-
| 2 || 010 || 00000100
|-
| 3 || 011 || 00001000
|-
| 4 || 100 || 00010000
|-
| 5 || 101 || 00100000
|-
| 6 || 110 || 01000000
|-
| 7 || 111 || 10000000
|}
Elle permet d'encoder N valeurs différentes avec N bits, zéro inclus. On perd une valeur par rapport à l'unaire, car le zéro n'et pas encodé à part du reste. La traduction du binaire vers le ''one-hot'' est réalisé par un circuit nommé décodeur, que nous verrons d'ici quelques chapitres. De même, la traduction d'un nombre encodé en ''one-hot'' vers le binaire est réalisée par un circuit électronique appelé l'encodeur, qui est en quelque sorte l'inverse de l'encodeur. Les deux circuits sont très utilisés en électronique, ce sont des circuits de base, que nous n'aurons de cesse d'utiliser pour fabriquer d'autres circuits.
L'utilité de ces deux représentations n'est pas évidente. Mais sachez qu'elle le deviendra quand nous parlerons des circuits appelés les "compteurs", tout comme ce sera le cas pour le code Gray. Elles sont très utilisées dans des circuits appelés des machines à état, qui doivent incorporer des circuits compteurs efficients. Et ces représentations permettent d'avoir des circuits pour compter qui sont très simples, efficaces, rapides et économes en circuits électroniques. La représentation unaire sera aussi utile à la toute fin du cours, dans les chapitres liés à l'exécution dans le désordre. Il en sera fait référence quand nous parlerons de fenêtres d'instruction, d'émission dans l'ordre, de ''scoreboarding'', etc.
Il faut aussi noter qu'il a existé des ordinateur qui manipulaient des nombres codés en unaire. Enfin presque, la représentation utilisée était proche de l'unaire, mais ressemblait fortement. De tels ordinateurs étaient appelés des ''ordinateurs stochastiques'' et nous les aborderons à la toute fin de ce wikilivre, dans un chapitre annexe portant sur les ordinateurs ternaires (qui comptent en base 3) et unaires.
==Les encodages hybrides entre décimal et binaire==
Pour terminer, nous allons voir les représentations qui mixent binaire et décimal. Il s'agit en réalité d'encodage qui permettent de manipuler des nombres codés en décimal, mais les chiffres sont codés en utilisant des bits. L'encodage ''Binary Coded Decimal'' est le plus connu de cette catégorie, mais il y en a quelques autres, qui sont moins connus. De telles représentations étaient très utilisées au début de l'informatique, mais sont aujourd'hui tombées en désuétude. Pour désigner ces encodages, nous parlerons d''''encodages bit-décimaux''' : décimaux pour préciser qu'ils encodent des nombres codés en décimal, bit pour préciser que les chiffres sont codés avec des bits.
De tels encodages étaient utilisés sur les tous premiers ordinateurs pour faciliter le travail des programmeurs, mais aussi sur les premières calculettes. Ils sont utiles dans les applications où on doit manipuler chaque chiffre décimal séparément des autres. Un exemple classique est celui d'une horloge digitale. Ils ont aussi l'avantage de bien se marier avec les nombres à virgule. Les représentations des nombres à virgule fixe ou flottante ont le défaut que des arrondis peuvent survenir. Par exemple, la valeur 0,2 est codée comme suit en binaire normal : 0.00110011001100... et ainsi de suite jusqu’à l'infini. Avec les encodages bit-décimaux, on n'a pas ce problème, 0,2 étant codé 0000 , 0010.
Il a existé des ordinateurs qui travaillaient uniquement avec de tels encodages, appelés des '''ordinateurs décimaux'''. Ils étaient assez courants entre les années 60 et 70, même s'ils ne représentent pas la majorité des architectures de l'époque. Avec eux, la mémoire n'était pas organisée en octets, mais elle stockait des chiffres décimaux codés sur 5-6 bits. Le processeur faisait des calculs sur des chiffres bit-décimaux directement.
Leur grand avantage est leur très bonne performance dans les taches de bureautique, de comptabilité, et autres. Les processeurs de l'époque recevaient des entiers codés en bit-décimal de la part des entrées-sorties, et devaient les traiter. Les processeurs binaires devaient faire des conversions décimal-binaire pour communiquer avec les entrées-sorties, mais pas les processeurs décimaux. Le gain en performance pouvait être substantiel dans certaines applications.
Les ordinateurs décimaux se classent en deux sous-types bien précis. Les premiers gèrent des entiers qui ont un nombre de chiffres fixes. Par exemple, l'IBM 7070 gérait des entiers de 10 chiffres, plus un signe +/- pour les entiers signés. Le processeur faisait les calculs directement en bit-décimal, il gérait des entiers faisant environ 10-15 chiffres décimaux et savait faire des calculs avec de tels nombres. Le second sous-type effectue les calculs chiffre par chiffre et géraient des nombres de taille variable, sans limite de chiffres ! Nous reparlerons de ces derniers dans un chapitre ultérieur, quand nous parlerons de la différence entre byte et mot. Pour le moment, ne gardez à l'esprit que les processeurs gérant un nombre fixe de chiffres décimaux, plus simples à comprendre.
===Le ''Binary Coded Decimal''===
Le '''''Binary Coded Decimal''''', abrévié BCD, est une représentation qui mixe binaire et décimal. Avec cette représentation, les nombres sont écrits en décimal, comme nous en avons l'habitude dans la vie courante, sauf que chaque chiffre décimal est directement traduit en binaire sur 4 bits. Prenons l'exemple du nombre 624 : le 6, le 2 et le 4 sont codés en binaire séparément, ce qui donne 0110 0010 0100.
{| class="wikitable"
|+ Codage BCD
|-
! Nombre encodé (décimal)
! scope="col" colspan="4" | BCD
|-
| 0 || 0 || 0 || 0 || 0
|-
| 1 || 0 || 0 || 0 || 1
|-
| 2 || 0 || 0 || 1 || 0
|-
| 3 || 0 || 0 || 1 || 1
|-
| 4 || 0 || 1 || 0 || 0
|-
| 5 || 0 || 1 || 0 || 1
|-
| 6 || 0 || 1 || 1 || 0
|-
| 7 || 0 || 1 || 1 || 1
|-
| 8 || 1 || 0 || 0 || 0
|-
| 9 || 1 || 0 || 0 || 1
|}
On peut remarquer que 4 bits permettent de coder 16 valeurs, là où il n'y a que 10 chiffres. Dans le BCD proprement dit, les combinaisons de bits qui correspondent à 10, 11, 12, 13, 14 ou 15 ne sont tout simplement pas prises en compte. Sur quelques ordinateurs, ces combinaisons codent des chiffres décimaux en double : certains chiffres pouvaient être codés de deux manières différentes. Il est aussi possible d'utiliser ces valeurs pour coder quelque chose d'autre que des chiffres. Par exemple, il est possible de les utiliser pour coder un signe + ou -, afin de gérer les entiers relatifs. Une autre possibilité, complémentaire de la précédente, utilise ces valeurs en trop pour coder une virgule, afin de gérer les nombres non-entiers. Les possibilités sont nombreuses.
Le support du BCD implique souvent que le processeur supporte des '''opérations BCD''', à savoir des opérations capables de travailler sur des opérandes en BCD et de donner un résultat en BCD. Il faut bien faire la différence entre les opérations en binaire et les opérations en BCD. Par exemple, on n'effectue pas une addition de la même manière en binaire et en décimal/BCD, même si les grandes lignes sont presque identiques. Les différences font que les processeurs doivent avoir des opérations différentes pour les deux encodages, de la même manière que les processeurs gèrent les opérations sur les flottants et les entiers séparément.
Le support du codage BCD est abandonné de nos jours, c'est surtout quelque chose qu'on trouve sur les anciens processeurs. Les architectures 8 et 16 bits supportaient à la fois des opérations binaires et des opérations BCD. Mais il a aussi existé une méthode intermédiaire, qui utilisait des additions binaires normales sur des opérandes BCD. L'idée est que le résultat de l'addition est incorrect, car les valeurs 10 à 15 peuvent apparaître comme chiffre dans le résultat. Mais on peut le corriger pour obtenir le résultat exact en BCD. Pour résumé, les processeurs faisaient des additions en binaire, et corrigeaient le résultat avec une opération spécifique pour obtenir un résultat en BCD. Nous en reparlerons dans le chapitre sur le langage machine et l'assembleur, dans lequel nous étudierons les différentes opérations que supporte un processeur.
===Les encodages compacts du BCD===
Des variantes du BCD visent à réduire le nombre de bits utilisés pour encoder un nombre décimal. Nous allons les appeler les '''encodages BCD compacts'''. Avec certaines variantes, on peut utiliser seulement 7 bits pour coder deux chiffres décimaux, au lieu de 8 bits en BCD normal. Idem pour les nombres à 3 chiffres décimaux, qui prennent 10 bits au lieu de 12. Cette économie est réalisée par une variante du BCD assez compliquée, appelée l''''encodage Chen-Ho'''. Une alternative, appelée le ''Densely Packed Decimal'', arrive à une compression identique, mais avec quelques avantages au niveau de l'encodage. Ces encodages sont cependant assez compliqués à expliquer, surtout à ce niveau du cours, aussi je me contente de simplement mentionner leur existence.
Les encodages BCD compacts sont utilisés par les programmeurs pour stocker des données dans un fichier, en mémoire, mais guère plus. Ils ne sont pas gérés par le processeur directement, on ne peut pas faire de calculs avec, le processeur ne gére pas d'opération BCD supportant de tels encodages. C'est théoriquement possible, mais ça n'a jamais été implémenté dans un processeur, le cout en circuit n'en valait pas la chandelle. Pour faire des calculs sur des nombres en BCD compact, on doit les décompresser et les convertir en BCD normal ou en binaire, puis faire les calculs avec des opérations BCD/binaire usuelles.
===L'encodage ''Excess-3''===
La représentation '''''Excess-3''''' (XS-3) est une variante du BCD, qui a autrefois était utilisée sur d'anciens ordinateurs décimaux. Il s'agit d'une sorte d'hybride entre une représentation par excès et BCD. Chaque chiffre décimal est codé sur plusieurs bits en utilisant une représentation binaire par excès. Le biais est de 3 pour la représentation XS-3, il en existe des variantes avec un excès de 4, 5, mais elles sont moins utilisées. Avec elle, la conversion d'un chiffre décimal se fait comme suit : on prend le chiffre décimal, on ajoute 3, puis on traduit en binaire.
{| class="wikitable"
|+ Codage en ''Excess-3''
|-
! Décimal
! scope="col" colspan="4" | Binaire
|-
| -3 || 0 || 0 || 0 || 0
|-
| -2 || 0 || 0 || 0 || 1
|-
| -1 || 0 || 0 || 1 || 0
|-
| 0 || 0 || 0 || 1 || 1
|-
| 1 || 0 || 1 || 0 || 0
|-
| 2 || 0 || 1 || 0 || 1
|-
| 3 || 0 || 1 || 1 || 0
|-
| 4 || 0 || 1 || 1 || 1
|-
| 5 || 1 || 0 || 0 || 0
|-
| 6 || 1 || 0 || 0 || 1
|-
| 7 || 1 || 0 || 1 || 0
|-
| 8 || 1 || 0 || 1 || 1
|-
| 9 || 1 || 1 || 0 || 0
|-
| 10 || 1 || 1 || 0 || 1
|-
| 11 || 1 || 1 || 1 || 0
|-
| 12 || 1 || 1 || 1 || 1
|}
L'avantage de cette représentation est que l'on peut facilement calculer les soustractions en utilisant une méthode bien précise (celle du complément à 10, je ne détaille pas plus). Le défaut est que le calcul des additions est légèrement plus complexe.
L'XS-3 a été utilisé sur quelques ordinateurs décimaux assez anciens, notamment sur l'UNIVAC I et II.
===Les codes bi-quinaires===
Les '''codes bi-quinaires''' utilisent un mélange entre encodage binaire, encodage ''one-hot'' et encodage décimal. L'idée de base derrière ces codes est d'utiliser la représentation ''one-hot'' pour coder un chiffre BCD. Le problème est que cela demande d'utiliser 10 bits pour encoder un chiffre BCD, ce qui est beaucoup. L'encodage bi-quinaire optimise le tout de manière à diviser par deux le nombre de bits requis pour l'encodage.
Pour simplifier, un chiffre encodé en bi-quinaire est composé de deux parties : une partie binaire de un bit, suivie par d'une partie quinaire encodée en représentation ''one-hot''. La partie quinaire encode un nombre allant de 0 à 4, ce qui prend 5 bits pour encoder les valeurs 0, 1, 2, 3 et 4. La partie binaire indique s'il faut ou non ajouter 5 à la valeur encodée par la partie quinaire. Ainsi, on peut coder tous les nombres de 0 à 9 : 0 à 4 si la partie binaire vaut 0, 5 à 9 si elle vaut 1. La valeur du chiffre se calcule comme suit :
* si la partie binaire vaut 0, elle est égale à la partie quinaire ;
* si la partie binaire vaut 1, on ajoute 5 à la partie quinaire.
L'originalité de ce codage est qu'il permet de facilement compter sur ses doigts : on a deux mains, cinq doigts. De nombreuses langues utilisent ce système pour coder des nombres, comme le Wolof. Et ce système est utilisé dans certains pays pour compter sur ses doigts, voire dans la vie de tous les jours.
[[File:Code Biquinaer.svg|vignette|Code bi-quinaire où partie binaire et quinaire sont encodées en représentation ''one-hot''.]]
L'encodage abordé au-dessus est une version simplifiée de l'encodage bi-quinaire, il en existe d'autres variantes. Le bi-quinaire vu juste au-dessus encode la partie binaire en binaire, et la partie quinaire en ''one-hot''. Mais il est aussi possible d'inverser l'encodage, à savoir encoder la partie binaire avec une représentation ''one-hot'', alors que la partie bi-quinaire est encodée en binaire normal.
Cependant, la version la plus intéressante utilise l'encodage ''one-hot'' pour les deux ! Elle est illustrée ci-contre. Avec cette dernière, un chiffre BCD est encodé sur 7 bits, dont 2 sont obligatoirement à 1 et les 5 restants sont à 0. Cette propriété permet de détecter qu'un bit a été inversé suite à une erreur de transmission ou un problème de stockage. Si un bit est inversé, le résultat aura 1 bit à 1 et 6 à 0, ou alors 3 bits à 1 et 4 à 0, ce qui ne colle pas. Le résultat après inversion ne donnera pas un chiffre bi-quinaire valide.
Une autre variante du bi-quinaire a été utilisée sur l'IBM 1401. Avec elle, la partie binaire disait si le chiffre était pair ou non, la partie quinaire encodait les valeurs 0, 2, 4, 6 et 8. Le chiffre se calculait en additionnant la partie binaire (0 ou 1) au nombre encodé par la partie quinaire. L'avantage de ce système est que la gestion des retenues lors des additions était grandement simplifiée.
Le bi-quinaire et ses variantes ont été utilisés sur d'anciens ordinateurs, dont l'IBM 650, l'UNIVAC Solid State et le UNIVAC LARC. La plupart utilisaient des variantes de l'encodage bi-quinaire proprement dit. Pour rentrer vraiment dans le détail, voici les encodages utilisés sur ces machines. La lecture est facultative, il est possible de passer directement à la section suivante :
{| class="wikitable"
|-
! !! IBM 650 !! Remington Rand 409 !! UNIVAC Solid State !! UNIVAC LARC
|-
| Chiffre || 1357-9 bits || 05-01234 || p-5-421 bits || p-5-qqq bits
|-
| 0 || 10-10000 || 0000-0 || 1-0-000 || 1-0-000
|-
| 1 || 10-01000 || 1000-0 || 0-0-001 || 0-0-001
|-
| 2 || 10-00100 || 1000-1 || 0-0-010 || 1-0-011
|-
| 3 || 10-00010 || 0100-0 || 1-0-011 || 0-0-111
|-
| 4 || 10-00001 || 0100-1 || 0-0-100 || 1-0-110
|-
| 5 || 01-10000 || 0010-0 || 0-1-000 || 0-1-000
|-
| 6 || 01-01000 || 0010-1 || 1-1-001 || 1-1-001
|-
| 7 || 01-00100 || 0001-0 || 1-1-010|| 0-1-011
|-
| 8 || 01-00010 || 0001-1 || 0-1-011 || 1-1-111
|-
| 9 || 01-00001 || 0000-1 || 1-1-100|| 0-1-110
|}
==Annexe : pourquoi l'octet existe==
Nous avons vu plus haut que les nombres encodés en binaire sont découpés en octets, des paquets de 8 bits. Et les octets sont présents partout dans la conception d'un ordinateur. Par exemple, la mémoire RAM d'un ordinateur moderne est organisée en octet. Elle contient des cases mémoire, chacune contenant un octet. Les octets, les cases mémoire, sont numérotées, ce qui permet de sélectionner un octet parmi tous les autres. La capacité de la mémoire est le nombre d'octets qu'elle contient, elle s'exprime en kilo-octets (1024 octets), mébi-octets (1024 kilo-octets), gibi-octets (1024 mébi-octets). Les octets sont donc présents partout dans l'informatique moderne.
{|class="wikitable"
|+ Contenu de la RAM, simplifié
|-
! Numéro
! Octet
|-
! 0
| 0000 1111
|-
! 1
| 0101 0101
|-
! 2
| 0111 0110
|-
! 3
| 0011 1010
|-
! 4
| 1001 1001
|-
! ...
| ...
|-
! 1023
| 0110 1010
|-
! 1024
| 0001 1110
|}
Mais pourquoi est-ce que les nombres encodés en binaire sont souvent découpés en octets ? Pourquoi ne pas utiliser des groupes de 3, 5, 6, 7, 9, 10 bits ? La question est d'autant plus légitime que d'anciens ordinateurs faisaient ainsi. Leurs mémoires étaient découpé en cases mémoire, mais chacune faisait 7 ou 9 bits, rien d'impossible à cela. À vrai dire, l'usage des octets au tout début de l'informatique était assez rare. Il y a eu un basculement progressif vers l'octet pendant les années 80-90. Reste à expliquer pourquoi. Si on devait résumer, les deux raisons principales sont le support du BCD et le support des caractères pour le texte. Voyons d'abord l'encodage des caractères en binaire.
===L'encodage du texte : ASCII et autres encodages===
Pour les caractères, les encodages se sont concentrés sur l'encodage des alphabets occidentaux. Ils demandaient d'encoder au minimum toutes les lettres, en majuscules et minuscules, plus les chiffres, une dizaine de caractères pour la ponctuation. À cela, il faut ajouter des symboles utiles comme %, µ, $, £, #, {} et bien d'autres. Au minimum, cela fait au minimum 52 lettres (26 lettres doublées pour les majuscules) + 10 chiffres + une dizaine de caractères pour la ponctuation : cela fait plus de 72 valeurs à encoder. Pour encoder cela en binaire, le minimum est de 7 bits, ce qui permet d'encoder 128 caractères : les 72 caractères minimum, plus quelques autres. L'encodage ASCII utilisait d'ailleurs 7 bits pour encoder des nombres.
On pourrait croire que c'était une raison suffisante pour utiliser des groupes de 7 bits. Mais il y a quelques problèmes avec cela. Premièrement, les encodages 7 bits pour le texte marchent bien pour gérer la langue anglaise et la plupart des langues occidentales, mais d'autres langues ont besoin d'encoder des caractères locaux. Les 128 caractères de l'ASCII ne sont pas suffisant, ce qui demande de passer de 7 à 8 bits. On retombe donc sur un octet ! D'ailleurs, les encodages ASCII améliorés utilisaient les 127 premières valeurs pour l'ASCII normal, mais les 128 suivantes encodaient d'autres caractères. Les encodages en question varient d'un pays ou d'une langue à l'autre, mais ils sont terriblement importants, ce qui est un argument pour l'usage des octets.
===Le support des données encodées sur un ''nibble''===
Une autre raison pour l'usage des octets est qu'il est facile de les découper en deux ''nibbles''. Pour rappel, les ''nibbles'' sont des groupes de 4 bits. Et il se trouve que de nombreuses données sont encodées sur 4 bits, ni plus ni moins. Les ''nibbles'' sont très important pour le support du BCD et de l’hexadécimal. Et c'était un avantage important au début de l'informatique, moins maintenant. Un octet regroupe deux ''nibbles'' : un ''nibble'' de poids faible, un ''nibble'' de poids fort.
[[File:Nibbles in a byte.PNG|centre|vignette|upright=2|Nibbles dans un octet.]]
Un chiffre BCD occupe un ''nibble'', ce qui fait que la taille idéale de l'unité mémoire doit être un multiple de 4 bits : 4 bits, 8 bits, 12 bits, 16 bits, etc. Un ''nibble'' de 4 bits est insuffisant pour encoder des caractères, 8 bits fonctionne parfaitement, pas besoin d'aller plus loin. De plus, l'usage des ''nibbles'' permet de gérer la représentation hexadécimale très facilement. Un chiffre hexadécimal est encodé sur exactement un ''nibble'', un octet regroupe deux chiffres hexadécimaux, deux octets 4 chiffres hexadécimaux, etc. Enfin, 8 bits est une puissance de deux, ce qui simplifie grandement l'encodage des entiers en binaire est est une source de nombreuses simplifications. Les circuits de l'ordinateur sont plus simple à concevoir quand ils manipulent des opérandes de 2, 4, 8, 16, 32, 64, 128 bits.
===L'influence sur la taille des opérandes===
Les anciens ordinateurs étaient conçus pour le calcul scientifique, ou du moins des applications similaires. Ils n'avaient pas besoin de supporter le BCD, ni même de gérer du texte. Ils ne faisaient que des calculs et utilisaient des opérandes flottantes. La taille des opérandes était dictée par des considérations calculatoires. La norme à l'époque était que les calculs scientifiques devaient gérer des opérandes pouvant aller jusqu'à 10 chiffres décimaux. Et cela demande d'utiliser des opérandes entiers de 35 bits, avec un bit en plus pour encoder des opérandes positives ou négatives. Les 36 bits étaient parfois découpés en 6 groupes de 6 bits chacun, au lieu des octets de 8 bits.
Mais ces groupes de 6 bits étaient souvent peu utilisés. Prenons l'exemple des mémoires RAM. Plus haut, j'ai dit qu'une mémoire RAM normale est composée de plusieurs octets, tous numérotés. Intuitivement, vous vous dites que les anciens ''mainframes'' remplaçaient ces octets par des groupes de 6 bits dans la RAM. Mais en réalité, l'équivalent des RAM de l'époque utilisait des groupes de 36 bits. les groupes de 36 bits étaient aussi numérotés, les numéros étant appelés des adresses mémoire. Par contre, les adresses/numéros étaient codés sur 12 ou 18 bits, c'est à dire sur 2 ou 3 groupes de 6 bits. La raison est que ces numéros sont manipulés par le processeur, qui gère des opérandes de 36 bits. L'idée est que le numéro ne prend que la moitié ou un tiers des 36 bits gérés par le processeur.
Avec l'apparition de l'informatique commerciale, les ordinateurs ont du s'adapter aux besoins des entreprises, pour gérer la comptabilité, des applications financières, des documents texte, etc. Et cela demandait de supporter à la fois le BCD et les caractères/lettres. Il a fallu trouver une unité mémoire capable de gérer à la fois nombres entiers, chiffres BCD et caractères. L'octet était parfait pour ça. Les ''mainframes'' sont adaptés en supportant des caractères codés sur 6, 7 ou 8 bits. Mais le support demandait d'utiliser des circuits qui découpaient un groupe de 36 bits en plusieurs caractères ou en plusieurs chiffres BCD. Le tout demandait des circuits de décalage et de masquage assez complexes. Les ordinateurs 8 et 16 bits manipulaient des octets nativement : leur mémoire RAM gérait des octets, le processeur traitait des opérandes de 8, 16 ou 32 bits. Ils n'avaient pas besoin de matériel complexe pour extraire un caractère ou un chiffre BCD d'un octet/doublet/multiplet. C'était un avantage que les anciens ''mainframes'' n'avaient pas.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'encodage des données
| prevText=L'encodage des données
| next=Les codes de détection/correction d'erreur
| nextText=Les codes de détection/correction d'erreur
}}
</noinclude>
cn24tdbbxdjf9ii1j0opfwvgmlkkn2l
Fonctionnement d'un ordinateur/Le renommage de registres
0
65844
775260
775196
2026-10-14T14:15:47Z
Mewtow
31375
/* La durée de vie des registres virtuels */
775260
wikitext
text/x-wiki
Pour améliorer l'exécution dans le désordre, les concepteurs de processeurs ont étudié des méthodes pour éliminer la grosse majorité des dépendances de données lors de l’exécution. Dans ce que j'ai dit précédemment, j'ai évoqué trois types de dépendances de données. Les dépendances RAW (''Read After Write'') sont des dépendances contre lesquelles on ne peut rien faire : ce sont de « vraies » dépendances de données. Elles imposent un certain ordre d’exécution de nos instructions. Mais les dépendances WAW (''write after write'') et WAR (''write after read'') sont de fausses dépendances, nées du fait qu'un emplacement mémoire est réutilisé, pour stocker des données différentes à des instants différents. Bien évidemment, l’imagination débridée des concepteurs de processeur a trouvé une solution pour supprimer ces dépendances : le '''renommage de registres'''.
Avant toute chose, précisons que ce chapitre abordera uniquement les techniques compatibles avec les exceptions précises. En clair, seulement les techniques utilisées sur les processeurs modernes. Il existe deux autres techniques de renommage de registre utilisées sur les processeurs sans exceptions précises : l'algorithme de Tomasulo et le ''scoreboarding''. Contrairement à ce que vous avez pu lire ailleurs, il y a une forme de renommage de registre dans le ''scoreboarding'', mais nous verrons cela dans quelques chapitres. Toujours est-il que ces deux techniques historiques étaient utilisées sur d'anciens ordinateurs, qui n'implémentaient pas la prédiction de branchement. Leurs techniques de renommage de registres étaient particulières, aussi nous les verrons dans un chapitre à part.
==Le renommage de registres : généralités==
Les dépendances WAR et WAW sont souvent 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. Un même nom de registre identifie une donnée différente à des instants différents du programme, ce qui force à exécuter les instructions qui utilisent ce registre dans l'ordre. D'où le fait que les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency''). Si on change l'ordre de deux instructions ayant une dépendance de données WAW ou WAR, on peut se retrouver dans une situation où les deux instructions veulent stocker des données différentes en même temps dans le même registre, ce qui n'est pas possible. Mais 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é. Un même nom de registre correspond alors à une donnée.
Une solution simple vient alors à l'esprit : conserver chaque donnée dans son propre registre et choisir le registre adéquat à l'utilisation. Un registre architectural correspond alors à plusieurs registres, chacun contenant une donnée bien précise. L'idée est qu'au lieu d'avoir un registre qui contient des données différentes à des instants différents, on a autant de registres que de données, ce qui améliore les possibilités de réorganisation des instructions. Il faut ainsi faire la distinction entre :
* '''registres architecturaux''', définis par le jeu d'instructions ;
* '''registres physiques''', physiquement présents dans l'ordinateur ;
* '''registres virtuels''' utilisés pour le renommage de registre mais invisibles pour le programmeur.
Les registres physiques regroupent l'ensemble des registres, à savoir les registres architecturaux et virtuels.
Pour illustrer l'idée, prenons l'exemple suivant, où un registre architectural nommé R6 est utilisé pour stocker trois données consécutives. Il mémorise d'abord le nombre 255, puis le nombre 2567763, puis zéro. On suppose que ces trois valeurs sont indépendantes, dans le sens où elles sont utilisées par des instructions sans dépendances RAW. En théorie, on pourrait exécuter ces instructions séparément, mais ce n'est pas possible. Le fait est que l'on doit d'abord faire les calculs avec l'opérande 255, puis ceux avec l'opérande 2567763, puis ceux avec l'opérande zéro. La raison est que pour que le calcul démarre, il faut que l'opérande soit disponible. Et l’usage d'un seul registre pour stocker des opérandes différentes à des temps différents limite les possibilités d’exécution en parallèle.
Avec le renommage de registres, ces trois données sont enregistrées dans des registres physiques séparés, dès qu'elles sont disponibles. Le processeur garde évidemment trace du fait que ces trois registres physiques correspondent au registre architectural R6. Si les trois valeurs n'ont pas de dépendances entre elles, les instructions liées peuvent être exécutées dans le désordre et s’exécuter en même temps. Ce qui est le cas dans le schéma suivant, où certaines instructions sont exécutées en avance, ce qui réserve les registres physiques en avance.
[[File:Renommage de registres - principe.png|centre|vignette|upright=2|Exemple où un registre architectural est utilisé pour contenir trois données successives.]]
: Pour les connaisseurs, le renommage de registres réécrit le programme exécuté dans une représentation appelée SSA, utilisée par les compilateurs lors de la compilation. Et cette réécriture se fait un peu de la même manière avec un compilateur. Là où le compilateur change le nom des variables pour passer en forme SSA, le processeur change les noms de registres pour obtenir de l'assembleur SSA interne au processeur.
===La durée de vie des registres virtuels===
La plupart des processeurs disposent d'un banc de registre physique unique, qui regroupe registres physiques et virtuels. Mais il existe quelques processeurs qui font autrement. Ils ont un banc de registre pour les registres architecturaux, et les registres virtuels sont placés ailleurs dans le processeur, typiquement dans le tampon de ré-ordonnancement (ROB) ou le ''scheduler''. Pour simplifier, considérons qu'ils sont dans un banc de registre spécialisé qui regroupe les registres virtuels. C'est un peu faux en soi, mais faisons comme si. Dans le reste de cette section, nous allons partir du principe que les registres virtuels et architecturaux sont séparés pour simplifier les explications, nous verrons les autres formes de renommage de registre plus tard.
Les registres virtuels ont une certaine durée de vie. On veut dire par là qu'une donnée écrite dans un registre virtuel y reste durant un certain temps, mais pas indéfiniment. Un registre virtuel est utilisé par une instruction en cours d'exécution dans le pipeline. Il nait lors du renommage de registre et meurt une fois l'instruction terminée. En clair, un registre virtuel dure durant toutes les étapes du pipeline qui suivent le renommage de registres. Une fois que l'instruction quitte le pipeline, qu'elle sort du ROB, le registre virtuel est inutile et doit laisser la place au registre architectural non-renommé.
Le résultat de l'instruction doit être transféré dans le registre architectural adéquat, le registre de destination de l'instruction. Si les registres virtuels et architecturaux sont séparés, il faut copier la donnée des registres virtuels vers les registres architecturaux. Utiliser un banc de registre unique résout le problème, mais n'allons pas trop vite, nous verrons cela en détail dans la suite. Toujours est-il qu'un registre virtuel est libéré quand l'instruction associée quitte le ROB.
Il est théoriquement possible de libérer un registre virtuel en avance, si on est certain qu'aucune instruction n'ira lire ce registre. Pour cela, le mieux est d'attribuer un compteur de lectures pour chaque registre. A chaque fois qu'une instruction renommée lit ce registre, ce compteur est incrémenté. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. L'intérêt d'une telle optimisation est d'utiliser au mieux le banc de registres. Le cout en circuit est variable : ajouter un compteur de quelques bits par registre n'est pas énorme, les circuits pour les incrémenter non plus, mais cela complexifie un peu l'unité de renommage.
Après avoir vu la mort des registres virtuels, voyons leur naissance. Le renommage de registre a lieu après le décodage, donc juste avant le passage dans le ''scoreboard'', le ''scheduler'', la file d'instruction ou toute autre structure dédiée à l'émission. En clair : les registres virtuels sont attribués juste avant émission et ne sont utiles qu'après. Les instructions susceptibles d'utiliser un registre virtuel sont donc les instructions émises ou prêtes à l'être, mais pas encore terminées. Les instructions en question sont soit en attente dans le ''scheduler'', soit en cours d'exécution dans le chemin de données, soit en attente dans le ROB. Ces dernières attendent que les instructions précédentes se terminent.
Il faut noter que toutes les instructions émises ou en passe de l'être ont été ajoutées dans le ROB juste après le renommage. Même les instructions en attente dans le ''scheduler'' sont dans le ROB. Pour résumer, le ROB garde la trace de ces instructions prêtes à être émises ou déjà émises.
===Le nombre idéal de registres virtuels===
Mine de rien, le contenu de la section précédente nous donne une seconde borne maximale sur le nombre de registres virtuels utiles. Supposons que chaque instruction fournit un résultat, stocké dans un registre virtuel rien que pour lui. Le nombre maximal d'instructions chargées dans le pipeline après émission est égal au nombre d'entrée dans le ROB. Il peut y avoir moins d'instructions, ce qui fait que des entrées du ROB sont inutilisées, mais supposons que nous soyons dans le meilleur des cas. Dans ce cas, on doit avoir un registre virtuel par instruction, ce qui fait au maximum un registre virtuel par entrée du ROB. Rien ne sert d'avoir plus de registres virtuels que d'entrées dans le ROB, il n'y aura pas assez d’instructions dans le pipeline pour tous les utiliser.
Après avoir vu une borne maximale, voyons une limite minimale. Non pas qu'il faille absolument plus de registres virtuels que cette limite, simplement qu'il est préférable d'en avoir que cette limite. Partons du principe que les instructions pouvant utiliser un registre virtuel sont celles qui sont soit déjà émises, soit en attente de l'être. Si on néglige les instructions en attente dans le ROB, alors les instructions émises sont dans les ALUs. Le nombre d'instruction est donc égal à la somme de : la taille du ''scheduler'' (instructions en attente), du nombre d'unités de calcul (instruction en cours d'exécution), unités d'accès mémoire inclues. Mieux vaut ne pas passer sous la somme taille du ''scheduler'' + nombre d'unités de calcul.
Un bon compromis est d'avoir un nombre de registre virtuel compris entre ces deux bornes. Le nombre réel de registre virtuels est très souvent inférieur au nombre d'entrées du ROB, car toutes les instructions ne fournissent pas de résultat à enregistrer dans les registres. C'est le cas pour les instructions de branchement, par exemple, ou les écritures en mémoire RAM. Par contre, il vaut mieux avoir plus de registres que d'entrées dans le ''scheduler''. La majorité es processeurs respectent ces deux limites basse et hautes, avec un nombre de registres réels entre les deux.
Les quelques rares exceptions étaient des processeurs qui implémentaient le renommage de registre pour la première fois, et où ce genre de détails n'étaient pas bien compris. Par exemple, on peut citer le PowerPC 604, qui avait 20 registres virtuels, alors que le ROB avait seulement 16 entrées. Les 4 registres virtuels en trop n'étaient pas utilisés. Le processeur qui a immédiatement suivi, le PowerPC 604, n'a pas fait cette erreur et a réduit le nombre de registres virtuels à seulement 16. Un autre exemple est le MIPS R10000, avec son ROB à 32 entrées, son ''scheduler'' a 48 entrées et ses 64 registres virtuels. Le processeur suivant, le R12000, corrigea partiellement le problème en augmentant le ROB à 48 entrées, ce qui était insuffisant, mais bienvenu.
===Le renommage des registres flottants et entiers===
De nombreux processeurs ont des registres architecturaux séparés pour les nombres flottants et entiers. Mais cela n'implique pas que le renommage de registre le soit. Les deux possibilités existent : une unité de renommage de registre partagée, ou au contraire deux unités de renommage séparées. Intel a tendance à utiliser la première solution sur ses processeurs haute performance, alors qu'AMD utilise la seconde solution.
[[File:Zen microarchitecture.svg|centre|vignette|upright=2|Microarchitecture Zen 1 des CPU d'AMD.]]
A ce propos, quelques rares processeurs ne renomment que que les registres flottants, ou que les registres entiers ! Pour donner un exemple, le processeur Nx586 ne renommait que les registres entiers, pour les instructions entières. L'IBM System/360 Model 91, quant à lui, faisait l'inverse. Les registres flottants étaient renommés, mais pas les registres entiers.
Sur les CPU x86, lorsque le renommage de registre a été introduit, il ne concernait que les registres flottants. Ce choix s'explique par l'organisation en pile de registres liée au jeu d'instruction x87, qui limitait grandement le parallélisme d'instruction. Le renommage permettait de fortement limiter la casse, sans compter qu'il permettait d'utiliser des noms de registre explicites à l’intérieur du processeur.
L'unité de renommage partagée sait à tout moment si un registre (physique ou virtuel) est un registre flottant ou entier en lui associant un simple bit. Le bit en question est propagé dans le ''scheduler'' et le reste du pipeline.
===Le renommage de registre total et partiel===
Le renommage de registre peut être total ou partiel. Avec le '''renommage total''', toutes les instructions voient leurs registres renommés. Il s'agit de la méthode la plus utilisée à ce jour, par simplicité de conception. Mais des processeurs assez anciens utilisaient un '''renommage partiel''', qui ne s’appliquaient qu'à certains types d'instruction bien précis.
Par exemple, on pourrait citer les processeurs Power1, Power2 et PowerPC 601. Sur le premier, les registres flottants étaient renommés seulement pour les instructions de lecture flottante. La raison est que le processeur n'a qu'une seule FPU, ce qui limite l'efficacité de l'exécution dans le désordre sur celle-ci, et fait que renommer les registres ne sert à rien. Le processeur Power 2 dispose lui de plusieurs FPU et le renommage de registres touche aussi les instructions flottantes arithmétiques, c'est un renommage total.
Les processeur qui utilisaient le renommage partiel étaient des processeurs assez anciens, qui faisaient avec un budget en transistors limité. De plus, la technique n'était pas très bien maitrisée, et était encore balbutiante. La technique a pourtant été proposée dès 1970 dans divers articles académiques, et raffinée pendant les années 70. Mais le budget en transistors du renommage de registre a fait qu'elle n'a pas été utilisée avant que la loi de Moore fasse son œuvre.
==Les différentes implémentations du renommage de registres==
Il existe plusieurs manières différentes d'implémenter le renommage de registre, que nous allons voir dans cette section. Mais avant toute chose, parlons du réseau de contournement. Pour simplifier les explications, appelons ''instruction productrice'' l'instruction qui fournit un résultat, et instruction consommatrice celle qui l'utilise comme opérande. Si une instruction consommatrice est chargée dans le pipeline quelques cycles après l'instruction productrice, les deux sont dans le pipeline en même temps. Dans ce cas, on pourrait penser que c'est le réseau de contournement qui gérerait le transfert du résultat de l'instruction productrice à l’instruction consommatrice. Et effectivement, c'est souvent le cas.
Mais les registres virtuels sont pris en compte dans le système de contournement. Si le résultat est écrit dans un registre virtuel, il peut aussi être lu dans ce registre virtuel. Lire un résultat dans les registres virtuels permet d'implémenter une forme de contournement. Tout cela pour dire qu'il y a un lien entre contournement et renommage de registres, les deux doivent être conçu de manière à communiquer entre eux. Typiquement, il y a toujours un réseau de contournement pour connecter la sortie d'une ALU aux entrées des autres. Ce dernier gère le cas où un résultat d’instruction est réutilisé comme opérande au cycle suivant. Mais pour réutiliser un résultat deux cycles après ou plus tard, c'est le système de renommage de registres qui s'en charge.
Ceci étant dit, nous allons étudier les implémentations principales du renommage de registre. Pour simplifier, il existe deux manières d'implémenter le renommage de registre. La première utilise deux bancs de registres : un pour les registres architecturaux, un autre pour les registres virtuels. Une autre implémentation regroupe registres virtuels et architecturaux dans un seul banc de registre. Mais d'autres implémentations font totalement autrement, en réutilisant les stations de réservation ou le tampon de ré-ordonnancement.
Dans ce qui suit, nous allons nous concentrer sur les principales : le renommage à banc de registre physique, le renommage dans le ROB, le renommage à registres virtuels. Nous allons laisser de côté le renommage dans les stations de réservation pour une raison très simple : elle n'est pas compatible avec les exceptions précises. Nous la verrons dans un chapitre à part, portant sur le ''scoreboarding'' et l'algorithme de Tomasulo.
Le trois méthodes que nous allons voir devraient vous rappeler quelque chose. Dans le chapitre sur le ''scoreboard et le ROB'', nous avions vu les différentes méthodes possibles pour garantir des exceptions précises, avec le ROB, le banc de registres futurs, et l'''history buffer''. En ajoutant le renommage de registres à ces trois techniques, on retombe sur le renommage à banc de registre physique, le renommage dans le ROB, le renommage à registres virtuels.
===Le renommage à banc de registres physiques===
Les processeurs modernes utilisent un seul banc de registres pour les registres architecturaux et virtuels, appelé '''banc de registres physiques''' (''physical register file''). Le tampon de réordonnancement contient soit des numéros de registres virtuels, des pointeurs vers le registre physique associé. Le ''scheduler'', quant à lui, contient soit des numéros de registres virtuels, soit directement les opérandes. Tout dépend de s'il utilise la lecture avant émission, ou après émission. Ci-dessous est illustré le cas avec ''lecture après émission''.
[[File:Renommage à banc de registres physiques.png|centre|vignette|upright=2|Renommage à banc de registres physiques.]]
Cette forme de renommage a une ressemblance assez forte avec la technique du ''tampon d'historique'' vue il y a quelques chapitres. Pour rappel, l'idée est d'utiliser un banc de registre unique, dans lequel les instructions écrivent à leur guise, mais avec la possibilité d'annuler les écritures en cas d'exception ou de mauvaise prédiction de branchement. Le tampon d'historique annulait les écritures en les exécutant à l'envers, en réécrivant les données écrasées. Ici, pas besoin de tampon d'historique, l'unité de renommage peut annuler les écritures fautives bien plus simplement.
Avec ce système, un registre physique peut être dans quatre états.
* Le premier état est celui d'un ''registre disponible'', près à servir de registre de destination. Par disponible, on veut dire que ce registre peut être réservé par l'unité de renommage, mais qu'il n'a pas encore été réservé. Ce n'est ni un registre virtuel, ni un registre architectural, juste un registre inutilisé.
* Le second état est l'''état réservé'', celui d'un registre virtuel qui attend qu'une instruction écrive son résultat dedans. L'état réservé réserve un registre en écriture, mais interdit les lectures dedans.
* Le troisième état est celui d'un registre virtuel dans lequel une instruction a écrit son résultat, mais l'instruction n'a pas encore quitté le ROB. Le registre est alors appelé un ''registre écrit''.
* Enfin, le quatrième est celui d'un ''registre architectural'', qui contient une donnée valide, dans le sens où il n'est associé à aucune instruction dans le ROB.
Un registre passe normalement par ces quatre états dans l'ordre disponible, virtuel réservé, virtuel écrit, architectural. Une fois que l'instruction attribuée quitte le ROB, le registre virtuel devient un registre architectural. Cependant, cela suppose que l'instruction quitte le ROB normalement, ce qui n'est pas le cas suite à une mauvaise prédiction de branchement ou autre. Dans ce cas, il repasse immédiatement à l'état disponible. L'unité de renommage des registre mémorise l'état de chaque registre pour savoir dans quel état il est.
Avec ce système, les données ne sont pas déplacées, ce qui a un avantage en termes de performance, de consommation énergétique et de simplification des circuits du processeur. Une fois enregistrées dans un registre, les données ne se déplacent plus. Avec un banc de registre virtuel séparé, elles sont recopiées d'un banc de registre virtuel vers un banc de registre architectural. Et les autres techniques que nous allons voir ont le même problème.
Un autre avantage est que les opérandes proviennent toutes du même endroit : le banc de registre unique. Avec un banc de registre séparés, les opérandes peuvent provenir soit des registres architecturaux, soit des registres virtuels. Les opérandes peuvent donc provenir du banc de registre architectural, mais aussi du banc de registre renommés. La conséquence est que la gestion des interconnexions est beaucoup plus compliquée, notamment quand on veut implémenter le contournement. Pas de problèmes de ce genre avec un banc de registre unique.
===Le renommage dans le tampon de réordonnancement===
Il est possible de faire le renommage dans le tampon de réordonnancement (ROB, pour ''ReOrder Buffer'')). Cela veut dire que les résultats des instructions sont mémorisés dans le ROB, et qu'on peut les lires directement dans le ROB. Il faut pour cela modifier le réseau de contournement, pour connecter le ROB aux entrées de l'ALU. Quand une instruction a tous ses opérandes de prêts, ceux-ci sont lus depuis le ROB et/ou les registres architecturaux. Avec cette technique, les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file''''', et les registres ,virtuels sont les entrées du ROB.
[[File:Renommage dans le tampon de réordonnancement.png|centre|vignette|upright=2|Renommage dans le tampon de réordonnancement.]]
Pour rappel, les entrées du ROB ont un champ pour mémoriser le résultat d'une instruction. Ici, c'est ce champ qui sert de registre virtuel. Pour gérer le contournement dasn le ROB, le ''scheduler'' détermine que tel registre architectural correspond en réalité à telle entrée du ROB. Les entrées du ROB deviennent donc adressables, elle ont un numéro, qui sert de numéro/nom de registre virtuel. De plus, le ROB mémorise l'état de chaque entrée, de chaque registre virtuel : vide, réservée pour un résultat, écrite avec un résultat.
Un défaut est que cela complexifie l'implémentation des interconnexions par rapport à un banc de registre physique. Il y a toujours un réseau de contournement pour réutiliser un résultat un cycle plus tard. Mais pour réutiliser un résultat deux cycles après ou plus tard, c'est le ROB qui s'en charge. Les opérandes peuvent être lues depuis trois sources : le ROB, le banc de registre architectural, et le réseau de contournement entre ALUs. Soit une source de plus qu'avec la technique précédente, ce qui complexifie les multiplexeurs de contournement. Et cela a un effet sur la latence des opérations, le temps de traversée de ces multiplexeurs n'est pas gratuit, sans compter le temps mis pour les signaux de commande pour les configurer.
Le renommage de registre est aussi plus compliqué. Au lieu de simplement remplacer un numéro de registre architectural par un numéro de registre physique, on doit faire la différence entre numéro de registre et adresse dans le ROB, pour savoir où lire les opérandes. Sans quoi ne sait pas configurer les multiplexeurs et choisir la bonne source pour les opérandes.
Un autre défaut est que les registres virtuels doivent être copiés dans les registres architecturaux quand une instruction quitte le ROB. La copie se fait ici du ROB vers le banc de registre architectural. Et copier la donnée a un cout en énergie que l'usage d'un banc de registre unique n'a pas.
Un détail est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Toutes les instructions passent dans le ROB, mais seulement une partie utilisent le "registre virtuel". Le hardware des entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon. Par contre, cela garantit que l'on ne tombe jamais à court de registres virtuels. Il n'y a pas de situations où le ROB peut accepter de nouvelle instructions, mais où il n'y a pas assez de registres virtuels pour.
===Le renommage avec un tampon de renommage===
Le dernier défaut évoqué dit que beaucoup d'entrées du ROB sont inutilisées et que les registres virtuels associés sont gâchés. Pour éviter cela, il est possible de sortir les résultats d'instruction du ROB et de les placer dans une mémoire FIFO complémentaire du ROB, appelée le '''tampon de renommage'''. Pour le dire autrement, le ROB amélioré est scindé en deux, avec le ROB proprement dit d'un côté, et un pseudo-ROB pour le renommage de registres et le contournement. Pour information, cette technique de renommage était utilisée dans d'anciens processeurs commerciaux, comme les Pentium Pro, le Pentium II, ou encore le Pentium III, ainsi que dans le Core 2 Duo.
La technique est souvent expliquée en décrivant le tampon de renommage comme un banc de registre séparé pour les registres virtuels, appelé le '''banc de registres renommés''' (''rename register file''). Il est séparé du banc de registres architecturaux, avec lequel il communique. La raison à cela est que le tampon de renommage est adressable alors que le ROB ne l'est pas. Il faut dire que le tampon de renommage a tout d'un banc de registre : il contient les registres virtuels et seulement ceux-ci, on peut l'adresser, lire des opérandes dedans, écrire des résultats dedans, etc.
Une fois que l'instruction attribuée quitte le ROB, le registre virtuel est libéré, à savoir qu'il repasse en état disponible. C'est à ce moment qu'à lieu la copie de la donnée dans le registre architectural adéquat. Il faut noter que le registre virtuel n'est pas effacé, il contient toujours l'ancienne donnée. Mais ce n'est pas un problème. S'il est réservé par une instruction, elle écrira dedans, ce qui écrasera cette donnée. Deux implémentations sont possibles :
* Avec une implémentation basique, le ROB contient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Lorsqu'une instruction quitte le ROB, le numéro de registre virtuel est envoyé au banc de registre renommé, le résultat est lu, puis copié dans le banc de registres architectural.
* Une autre implémentation transforme le banc de registre en mémoire FIFO, dont les entrées sont adressables, ce qui en fait un mini-ROB secondaire. Le ROB n'a ainsi pas besoin de mémoriser des numéros de registres virtuels, il a juste à envoyer l'ordre de transfert vers les registres architecturaux, et le ROB secondaire sait quel registre virtuel lire.
[[File:Renommage à banc de registres renommés.png|centre|vignette|upright=2|Renommage à banc de registres renommés.]]
La technique partage tous les défauts du renommage dans le ROB, sauf pour ce qui est de l'utilisation des entrées. Le réseau de contournement est plus compliqué car les opérandes peuvent venir de trois sources : les deux bancs de registres et le réseau de contournement. Les registres virtuels doivent être copiés dans les registres architecturaux, avec tout ce que cela implique. Déjà, déplacer des données implique un cout en énergie et en consommation d'électricité, qu'il vaut mieux éviter. De plus, il faut ajouter un port de lecture sur le banc de registre renommés, pour copier un registre sans nuire aux performances. Le banc de registres physiques n'a pas à être modifié : le port d'écriture existant suffit pour la copie, vu qu'on n'écrit dedans que pour copier des résultats d'instructions validées/terminées.
Par contre, le cout en transistors est plus faible qu'avec le renommage dans le ROB, vu que le banc de registre a moins de registres virtuels que le ROB. La technique n'impose pas d'avoir autant de registres virtuels que d'entrées dans le ROB, ce qui permet d'en réduire le nombre. Par contre, comparé à l'usage d'un banc de registres unique, le cout en transistors est plus élevé. La différence se fait surtout au niveau des ports de lecture/écriture du banc de registres. La raison est que l'on doit ajouter un port de lecture pour copier les résultats dans les registres architecturaux. Du moins, dans le cas le plus simple. C'est le cas sur un processeur qui permet de compléter l'exécution d'une instruction par cycle. Mais certains processeurs peuvent terminer l'exécution de plusieurs instructions par cycle. C'est notamment le cas des processeurs dits superscalaires, qui sont capables de charger, exécuter et terminer plusieurs instructions par cycle. Ils auront droit à leur chapitre dédié, mais passons. Toujours est-il que si plusieurs instructions peuvent se terminer en même temps, il faut ajouter autant de ports de lecture sur le banc de registre pour copier leurs résultats.
===Le renommage physique-virtuel===
Les méthodes mentionnées au-dessus ont un léger problème : beaucoup de registres physiques sont gâchés, car attribués à une instruction dès l'étage de renommage et libérés lorsque on est certain qu'aucune instruction ne lira le registre physique attribué. Et parfois, les registres sont alloués trop tôt, alors qu'ils auraient pu rester libres durant encore quelques cycles. C'est notamment le cas quand l'instruction renommée attend un opérande dans le ''scheduler'', ou quand le résultat est en cours de calcul dans l'unité de calcul.
Ces situations ont toutes la même origine : le renommage a lieu tôt dans le pipeline, pour garder les dépendances entre instructions. Mais dans les faits, rien n'oblige à utiliser les registres architecturaux pour conserver les dépendances. On peut tout simplement attribuer un tag à chaque résultat d'instruction, tag qui ne correspond pas à un registre architectural. Et ce tag sera alloué à un registre architectural le plus tard possible, quand l'instruction fournira son résultat. Ce genre de méthodes a été formalisée avec ce qu'on appelle la technique du '''banc de registres physiques-virtuels''' (physical-virtual register file).
Cette méthode demande d'ajouter une seconde table de correspondance, qui fait le lien entre le tag et le registre physique. De plus, la table d’alias de registres doit être modifiée : elle ne doit pas seulement faire la correspondance entre le nom de registre et le tag, mais aussi avec le registre physique s'il est déjà attribué. Ainsi, lors du renommage en sortie du décodeur, on peut renommer l'instruction avec les registres physiques si ceux-ci sont connus lors du renommage, et renommer avec des tags dans le cas contraire. Il faut noter que cette méthode a un léger problème : quand une instruction termine et que le résultat doit se voir attribuer un tag, il se peut parfaitement qu'il n'y ait plus de registre physique de libre. Les solutions pour régler ce problème sont assez complexes, aussi je n'en parlerai pas ici.
==L’unité de renommage==
Pour commencer, sachez qu'il existe deux méthodes pour implémenter l'unité de renommage. La première utilise une unité à part, séparées, qui contient de nombreuses mémoires caches/FIFO pour stocker des informations nécessaires pour le renommage de registres. La seconde mémorise les informations de renommage dans le ROB ou le tampon de renommage, voir ailleurs. Les deux types d'unités de renommage sont assez différentes. Pour les désigner, je vais parler d''''implémentation par ''tag''''' et d''''implémentation associative'''.
De plus, les deux types d'unités ne sont pas utilisées sur les mêmes implémentations. Par exemple, avec un banc de registre physique, il est naturel d'utiliser une implémentation par ''tag'', l'autre implémentation n'est pas pratique. Aussi, tous les processeurs avec un banc de registre physique utilisent une unité de renommage pat ''tag''. Mais pour le renommage dans le ROB, c'est l'inverse : les deux implémentations sont possibles, mais il est préférable d'utiliser une implémentation associative. Pour simplifier les explications, je vais commencer par décrire le premier type, avec un banc de registre physique.
{|class="wikitable"
|-
!
! Implémentation par ''tag''
! Implémentation associative
|-
! Banc de registre physique
| Seule implémentation possible
|
|-
! Renommage dans le ROB
| Possible, certains processeurs utilisent la technique
| Possible, certains processeurs utilisent la technique
|-
! Renommage dans un tampon de renommage
| Possible, tous les processeurs utilisent la technique
| Possible, aucun processeur connu
|}
===Les unités de renommage par ''tag''===
Les registres architecturaux sont identifiés par des noms de registre, tout comme les registres physiques. Les noms de registre des registres physiques sont appelés des '''tags'''. Les registres physiques sont bien plus nombreux que les registres architecturaux, sans quoi le renommage de registre n'a aucun intérêt. La conséquence est que les numéros de registres physiques sont plus grands que les numéros de registres architecturaux. Le renommage de registres remplace les noms/numéros de registres architecturaux par des numéros de registres physiques, par des ''tags''. D'où le terme de ''renommage'' de registre. Ce remplacement est effectué dans un étage supplémentaire du pipeline, intercalé entre le décodage et l'émission.
[[File:Renommage de registres.png|centre|vignette|upright=2|Renommage de registres.]]
Le remplacement se fait cependant différemment pour le registre de destination et les registres d'opérandes. Le registre de destination est associé à un registre virtuel disponible. L'allocation du registre de destination prend un registre disponible et le place en état réservé. Une nouvelle correspondance entre registre architectural et virtuel est alors crée. Mais pour les opérandes, c'est autre chose. Les opérandes sont soit dans un registre architectural, soit dans un registre virtuel en état écrit. Les noms de registres architecturaux pour les opérandes doivent être remplacé par le nom de registre adéquat, déjà attribué. Une sacrée différence : on attribue un registre virtuel pour le résultat, les opérandes ont déjà un registre virtuel attribué.
Un autre point est que l'unité de renommage ne fait pas que modifier les registres opérande/de destination. Elle gère aussi la disponibilité des opérandes. Plus haut, on a dit qu'un registre virtuel pouvait être dans quatre états. Soit il est disponible, soit il est réservé, soit il est écrit, soit la donnée est dans un registre architectural. L'unité de renommage est au courant de l'état de chaque registre. Les quatre états possibles sont utilisés pour diverses raisons.
Et il y a deux manières pour mémoriser la correspondance entre un registre physique et un registre architectural. La première est d'utiliser un circuit à part, qui fait partie de l'unité de renommage, l'autre est d'intégrer les correspondances dans le ROB ou toute autre structure dédiée. Pr exemple, on peut ajouter la correspondance dans le ROB, dans le ''scheduler'', éventuellement dans le banc de registres renommés ! Dans ce qui suit, nous allons parler du cas où on utilise une mémoire à part, partie intégrante de l'unité de renommage de registre. Nous verrons l'intégration dans le ROB après, de même que la relation avec les quatre techniques précédentes.
===Renommer le registre de destination : la liste de disponibilité===
Renommer un registre de destination revient à lui attribuer un registre virtuel inutilisé. Pour cela, certaines techniques de renommage de registres mémorisent la liste des registres vides/disponibles dans une petite mémoire : la '''liste de disponibilités''' (''free list''). L'implémentation de la liste de disponibilité varie grandement d'un processeur à l'autre. Avec certaines méthodes de renommage de registre, qui impliquent un ROB, on peut même s'en passer totalement.
Dans sa version la plus simple, elle contient un bit pour chaque registre, qui indique s'il est disponible ou non. Le bit en question est mis à jour quand une instruction entre/quitte le tampon de réordonnancement avec ce registre comme opérande. Lorsqu'un registre est attribué, il quitte la liste de disponibilités. Lorsque son instruction quitte le ROB, le bit de disponibilité est mis à jour. Une version plus évoluée utilise une mémoire FIFO qui contient des noms de registres. Le renommage du registre de destination utilise cette FIFO pour sortir le numéro de registre virtuel adéquat. Dans la suite, nous partirons du principe que c'est cette méthode qui est utilisée.
Il faut noter que si le renommage est fait dans le ROB, la liste de disponibilité est inutile. La raison est que le ROB est déjà une mémoire FIFO, comme la liste de disponibilité. De plus, les registres virtuels sont dans le ROB. Attribuer un registre virtuel consiste simplement à mobiliser une entrée vide du ROB, en enfilant l'instruction dedans. Le ROB sait quelle est la prochaine entrée à allouer à une instruction émise, du fait de son caractère FIFO.
===La table de correspondances de registres===
Le renommage du registre de destination attribue un registre virtuel pour un résultat d'instruction. Reste qu'il faut maintenir cette attribution tant que l'instruction ne s'est pas terminée. Pendant toute la durée de vie de l'instruction, il faut se souvenir que tel registre virtuel correspond à tel résultat d'instruction, mais aussi à tel registre architectural. En effet, les instructions suivantes vont lire ce résultat pour l'utiliser comme opérande. Elle encoderont ce résultat/opérande avec le registre architectural censé contenir l'opérande. Le renommage de registre devra alors remplacer les registres opérande par le registre virtuel adéquat, qui contient l'opérande.
Le renommage des registres opérande est le rôle de la '''table d’alias de registres''' (''register alias table''), une mémoire qui contient le registre virtuel associé à chaque registre architectural. Elle mémorise précisément une table de correspondance entre registre architectural et registre virtuel/physique. La table d'alias contient une correspondance par registre architectural, qui change au gré du renommage des instruction. De plus, elle contient un bit qui indique si le registre a été renommé ou non. S'il n'a pas été renommé, alors l'opérande est disponible dans les registres architecturaux, pas dans un registre virtuel. C'est très utile sur les processeurs avec un banc de registre séparés pour les registres architecturaux. Cela permet de savoir où lire les opérandes.
La correspondance en question est mise à jour quand une instruction est émise, et quand elle termine. Premièrement, elle est mise à jour à chaque fois qu'un registre de destination est renommé. En clair, la table d’alias de registres est mise à jour à chaque lecture dans la table de disponibilité. Le registre virtuel choisi dans la liste de disponibilités est attribué au registre architectural de destination du résultat. Il suffira de réutiliser cette correspondance par la suite.
Deuxièmement, elle est mise à jour quand une instruction termine, quand elle quitte le ROB. Le ROB envoie alors le numéro du registre virtuel qui contenait le résultat enregistré dans les registres architecturaux. Le numéro est alors enregistré dans la table de disponibilité. De plus, la correspondance associée dans la table d'alias est effacée.
[[File:Renommage de registres - circuits.png|centre|vignette|upright=2|Unité de renommage de registres.]]
Il existe deux façons pour implémenter la table d'alias. La plus ancienne consiste à utiliser une mémoire associative dont le tag contient le nom du registre architectural et la donnée le nom du registre physique. Sur les processeurs plus récents, on utilise une mémoire RAM, dont les adresses correspondent aux noms de registres architecturaux, et dont le contenu d'une adresse correspond au nom du registre physique associé. On n'a alors qu'une seule correspondance entre registre physique et registre architectural, mais cela ne pose pas de problème si on renomme les instructions dans l'ordre d'émission (ce qui est toujours fait). Notons que la table l'alias doit avoir deux ports de lecture : un pour chaque opérande. Et cela vaut peu importe son implémentation : le cache comme la RAM doivent avoir deux ports.
==L'interaction entre renommage de registres et autres optimisations==
L'unité de renommage de registre ne fait pas que du renommage de registre, mais joue aussi un rôle dans l'exécution dans le désordre, et prend en charge des rôles annexes à son rôle principal. Par exemple, elle est impliquée pour corriger les mauvaises prédiction de branchement, les exceptions matérielle ou toute situation qui demande de vider le pipeline. En dehors de ces situations, un registre passe normalement par ces quatre état dans l'ordre disponible, réservé, écrit, architectural. Mais ce n'est pas le cas lors d'une mauvaise prédiction de branchement ou autre. Dans ce cas, tous les registres passent immédiatement à l'état disponible, sauf les registres architecturaux.
===La récupération après une prédiction invalide===
Quand une exception ou une mauvaise prédiction de branchement a lieu, les registres virtuels contiennent des données potentiellement invalides, contrairement aux registres architecturaux. En cas de mauvaise prédiction de branchement, la table d’alias de registres est partiellement vidée. Par vidée, on veut dire qu'on élimine les correspondances avec les registres virtuels invalidés. De plus, on doit remettre la table d’alias de registres et la table de disponibilités dans l'état antérieur au chargement de l'instruction qui a déclenché la mauvaise prédiction de branchement ou l'exception matérielle. Et cela peut se faire de différentes manières.
La première solution s'applique au renommage dans le ROB. Il suffit de stocker dans le tampon de réordonnancement ce qui a été modifié dans l'unité de renommage de registres par l'instruction correspondante. Ainsi, lorsqu'une instruction sera prête à valider, et qu'une exception ou mauvaise prédiction de branchement aura eu lieu, les modifications effectuées dans l'unité de renommage de registres seront annulées les unes après les autres.
Une autre solution consiste à garder un historique des changements fait dans la table d'alias. Lorsqu'une mauvais prédiction est détectée, le processeur traverse l'historique et annule les changements faits un par un. Le tout est assez lent, vu que la traversée se fait un renommage à la fois. Une optimisation utilise une copie valide de la table d’alias de registres dans une mémoire à part, pour la restaurer au besoin. Si jamais le processeur détecte un branchement, la table d’alias de registres est sauvegardée dans une mémoire intégrée au processeur. On peut utiliser plusieurs mémoires de sauvegarde de ce type, pour gérer une succession de branchements.
Une dernière solution utilise deux tables d'alias : une correspondant à l'état spéculatif du CPU, l'autre correspondant à l'état non-spéculatif. La première est utilisée pour renommer les µops, la seconde sert de sauvegarde en cas de mauvaise prédiction de branchement. Quand une mauvaise prédiction de branchement est détectée, la table d'alias spéculative était remis à zéro et le contenu de la seconde table d'alias était recopié dedans. La technique a été utilisée sur le Pentium 4, de microarchitecture Netburst, et bien d'autres processeurs.
[[File:MIT 6.823 Computer System Architecture, Fall 2005 - Pentium III and Netburst - 4332527833.jpg|centre|vignette|upright=2.5|Tables d'alias du Pentium 4.]]
===La gestion des opérandes disponibles : "lecture avant émission" et "lecture après émission"===
Passons maintenant à un détail d'implémentation de l'exécution dans le désordre. La '''lecture après émission''' utilise des le ''schedulers'' basiques, alors que la '''lecture avant émission''' utilise des stations de réservation. Rappelons que la différence est que les premières émettent les instruction puis lisent les registres, alors que la seconde lit les opérandes depuis les registres juste après le renommage de registre, pour les stocker dans le ''scheduler''. La différence est donc que les étages sont répartis différemment. Avec la "lecture avant émission", l'étage de lecture des opérandes est placé entre l'étage de renommage de registres et l'étage d'émission. Avec la "lecture après émission", l'étage de lecture des registres est situé après l'étage d'émission/''select/wakeup''.
Les deux méthodes de lecture avant ou après émission montrent une grande différence pour ce qui est de déterminer la disponibilité des opérandes. Pour la lecture après émission, le renommage de registre n'est pas impliqué. Mais avec la lecture avant émission, les opérandes sont lues dans les registres entre le renommage des registres et l'entrée dans la station de réservation. Ce qui implique qu'on sait quelles sont les opérandes disponibles lors du renommage de registres. Pour cela, l'unité de renommage de registre contient, pour chaque registre virtuel, un bit ''ready'' qui indique que le registre renommé contient une opérande est disponible. S'il est à 0, l'instruction est placée dans le ''scheduler'', et attend que ses opérandes soient disponibles. S'il est à 1, les opérandes sont soit lues dans le registre et placées dans le ''scheduler'', soit lues au moment d'exécuter l'instruction.
L'ensemble de ces bits est en plus des bits de disponibilité de la station de réservation, dans l'implémentation la plus simple. Les deux sont alors mis à jour en même temps. Une autre solution regroupe les bits de disponibilité dans une structure matérielle appelée la '''table de disponibilité''', qui est consultée à la fois par les stations de réservation et l'unité de renommage de registres. L'avantage de cette solution est que les signaux de réveil sont envoyés à une seule structure matérielle, pas plus. De plus, il n'y a pas de duplication des bits de disponibilité. Non pas que quelques bits fassent une grosse différence, mais tout est bon à prendre.
Notons qu'avec un banc de registres physique, le bit de disponibilité se déduit des quatre états que peut prendre un registre physique. L'unité de renommage de registre doit sait déjà quelles sont les opérandes indisponibles, celles qui ont déjà été calculées, et celles enregistrées dans un registre architectural. Notons qu'un registre virtuel, une fois attribué, peut être en état réservé ou écrit. Seul l'état écrit correspond à une opérande disponible, l'unité de renommage doit donc pouvoir faire la différence. Pour savoir si un registre virtuel contient une donnée valide, la table d’alias de registres lui associe un bit de validité qui est mis à jour lors de l'écriture du résultat dans le registre virtuel correspondant. Elle doit aussi mémoriser quels registres architecturaux ont une donnée valide.
Rappelons que le processeur sait qu'une opérande est disponible parce qu'un signal de réveil est généré dans le pipeline, pour dire que telle opérande est disponible. Le signal transmet l'opérande avec le numéro de registre (virtuel ici). Le numéro de registre transmis est alors comparé avec tous les numéros de registre des opérandes, dans le ''scheduler'' et/ou les stations de réservation. Il est possible qu'une instruction soit dans l'étape de renommage alors que le signal de ''réveil'' est envoyé. Si rien n'est fait, l'instruction sera insérée dans le ''scheduler'' avec une opérande indisponible, alors qu'elle l'est. Elle restera alors indéfiniment dans le ''scheduler'', vu que le signal de ''réveil'' n'est généré qu'une seule fois.
Pour éviter cela, le signal de ''réveil'' doit aussi être envoyé aux instructions dans l'unité de renommage de registre. Une solution pour éviter cela est d'enregistrer directement les noms de registres renommés dans le ''scheduler'', dès la sortie de l'unité de renommage. Les noms de registres sautent un étage de pipeline, donc. La comparaison avec les signaux de réveil se fait alors dans le ''scheduler'', pas besoin de rajouter un comparateur dans l'étage de lecture des registres. Une autre solution est d'utiliser une table de disponibilité unique. Les signaux de réveil ne sont alors pas perdus.
==Les optimisations liées au renommage de registres==
Le renommage de registre permet d'implémenter certaines optimisations annexes, qu'on ne croirait pas reliées au renommage de registre au premier abord. Par exemple, saviez-vous que le renommage de registre permet d'exécuter certaines instructions directement dans l'unité de renommage ? De nombreuses optimisations éliminent certaines instructions, en les remplaçant par des renommages de registres. Il existe plusieurs optimisations de ce genre, qui portent les noms barbabres d'élimination des MOV, de calcul trivial, et de remplacement d'idiomes.
===L'élimination des MOV===
La première optimisation de ce genre élimine les instructions de copie d'un registre dans un autre (les MOV) ou d’échange entre deux registres (XCHG). Ce qui explique pourquoi l'optimisation en question porte le nom d''''élimination des MOV''' (''MOV elimination''), sous-entendu, des MOV inter-registres.
L'élimination des MOV est une technique implémenté sur les processeurs à banc de registre physique. En clair, il faut qu'il y ait un seul banc de registre. C'est important, car c'est la seule méthode qui fait que les registres virtuels n'ont pas à être copiés dans les registres architecturaux, ce qui facilite grandement l'implémentation d’une technique visant à éliminer des copies de registres. L'idée est qu'après une copie, le contenu des deux registres source et destination est identique tant qu'aucun des deux registres ne subit d'écriture. On peut alors utiliser un seul registre physique pour la valeur mémorisée, toute lecture des deux registres lisant celui-ci. Lors d'une écriture dans un de ces deux registres, le renommage attribuera un nouveau registre physique pour la nouvelle valeur écrite.
: Formellement, il s'agit d'une optimisation de type '''''copy-on-write'''''.
===Le remplacement d'idiomes===
Le même genre d'optimisation peut être effectué avec des instructions inutiles, dont le résultat vaut zéro. En effet, il existe de nombreuses méthodes pour mettre un registre à zéro. Faire un XOR entre le registre et lui-même, le soustraire à lui-même ou le multiplier par zéro sont quelques exemples. Et de tels idiomes sont utilisés par les compilateurs, au lieu d'un simple MOV 0 -> registre, qui copie un 0 dans le registre. La raison est que les instructions MOV sont assez longues, vu qu'elles doivent intégrer une constante immédiate.
Le renommage de registres peut être utilisé pour éliminer ces opérations et les remplacer par une mise à zéro du registre. L'optimisation en question porte le nom de '''remplacement d'idiomes'''. Elle est implémentée depuis les processeurs Pentium de microarchitecture P6, et leurs équivalents AMD.
Un autre avantage de cette optimisation est que cela casse de fausses chaines de dépendances. Par exemple, une opération "reg XOR reg" faire croire au processeur qu'il y a une dépendance de donnée entre cette instruction, et les instructions précédente qui écrivent dans le registre. Le XOR est donc exécuté après ces instructions faussement dépendantes, alors que la mise à zéro du registre pourrait se faire en parallèle en utilisant un registre virtuel séparé. Les techniques précédentes permettent d'éliminer ces fausses dépendances.
===Les optimisations de calculs triviaux===
Il existe aussi des opérations dont le résultat est une des opérandes. On pourrait citer comme exemples les décalages par 0, les additions et soustractions avec 0, la multiplication par 1, et bien d'autres. En utilisant intelligemment le renommage de registres, ces calculs ne sont pas effectués. Les techniques qui permettent cela sont des techniques dites de '''calcul trivial''' (trivial computation).
La détection des calculs simplifiables demande de comparer les opérandes avec 0 ou 1, via un paquets de comparateurs regroupés dans une unité de détection des calculs triviaux. Leur simplification se fait dans la table de renommage de registres, le registre de destination de l'instruction simplifiée étant renommé pour pointer sur l'opérande/résultat. Si le résultat est nul, on considère que la correspondance est invalide et on met le registre physique à une valeur invalide, similaire au pointeur NULL du C. Si un calcul lit un registre physique invalide, celui-ci est automatiquement simplifié par l'unité de renommage, l'autre opérande étant alors attribué automatiquement comme registre de destination du résultat dans la table de renommage.
Mais cette technique a tendance à modifier la latence des instructions, qui est réduite après simplification. Cela pose problème au niveau des circuits d'émission et du planificateur, qui n'aiment pas les latences variables, comme on l'a vu il y a quelques chapitres.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les schedulers et stations de réservation
| prevText=Les schedulers et stations de réservation
| next=Les schedulers larges et leurs optimisations
| nextText=Les schedulers larges et leurs optimisations
}}
</noinclude>
e1joh7zx1q7bn2nvp6u9oiw0elrzbnh
Fonctionnement d'un ordinateur/L'exécution dans le désordre
0
65869
775300
775251
2026-10-14T18:04:51Z
Mewtow
31375
775300
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 fusionne et l'unité d'émission. Le résultat de cette fusion est que la file de µops n'est plus une mémoire FIFO proprement dite. Les µops sont insérées dans l'ordre, mais peuvent en sortir dans le désordre (être émises dans le désordre). Le résultat de cette fusion est un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est ''fenêtre d'instruction'', 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'']]
Un détail important est la relation entre ''scheduler'' et ROB. 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é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.
==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, avec 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, 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 '''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 (l'unité de renommage de registres).
* Le '''système mémoire''' regroupe les caches de données, la ''load/store queue'', 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 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.
===Les défauts de cache deviennent 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. 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>
arb1g9nicx14pt0hk72wj2xpqgmkh3b
Fonctionnement d'un ordinateur/Le scoreboard et le ROB
0
65886
775261
775243
2026-10-14T14:27:06Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775261
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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'' était initialement utilisé pour désigner le ''scoreboard'' du CDC 6600, dont l'implémentation était très particulière, et qui aura droit à un chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui est 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, grâce au système de remise en ordre des écritures, à la fin du pipeline.
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient le ''scoreboard''. Le ''scorebaord'' détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre au ''scoreboard''. Le ''scoreboard'' recoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. Le ''scoreboard'' doit gérer ces dépendances de lui-même, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que le ''scoreboard'' doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 avec ''scoreboard'' sans exceptions précises. L'absence d’exceptions précises simplifie grandement le design du processeur : pas besoin de ROB, de tampon d'historique, de banc de registre futur, etc. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le ROB est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec un ROB pour gérer les exceptions précises.
Les processeurs avec un ''scoreboard'' et sans ROB sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
===Les dépendances ''Write After Write''===
Plus haut, j'ai sous-entendu que l’absence d'exceptions précises avait des effets largement au-delà des problèmes liés aux exceptions et à la prédiction de branchement. Le ROB remet en ordre les écritures, afin d'éviter tout phénomène d'inversion des écritures. Et l'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme.
Prenons deux instructions proches, qui écrivent dans le même registre. 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écises.
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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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.
En absence d'exceptions précises, le ''scoreboard'' doit savoir nà tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Pour comparer, avec un ROB, l'unité d'émission communique surtout avec le ROB, qui lui indique quelles instructions viennent de se terminer. Et éventuellement les unités de calcul, pour savoir lesquelles sont libres. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
===Les dépendances ''Write After Read''===
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scorebaords'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si le ROB est présent, il est unique, car les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises et n'avaient pas de ROB.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
<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>
8y3hlbj12xuu8yb1ez2lzwjtzml1d1n
775262
775261
2026-10-14T14:31:23Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775262
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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'' était initialement utilisé pour désigner le ''scoreboard'' du CDC 6600, dont l'implémentation était très particulière, et qui aura droit à un chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui est 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, grâce au système de remise en ordre des écritures, à la fin du pipeline.
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient le ''scoreboard''. Le ''scorebaord'' détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre au ''scoreboard''. Le ''scoreboard'' recoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. Le ''scoreboard'' doit gérer ces dépendances de lui-même, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que le ''scoreboard'' doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 avec ''scoreboard'' sans exceptions précises. L'absence d’exceptions précises simplifie grandement le design du processeur : pas besoin de ROB, de tampon d'historique, de banc de registre futur, etc. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le ROB est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec un ROB pour gérer les exceptions précises.
Les processeurs avec un ''scoreboard'' et sans ROB sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
En absence d'exceptions précises, le ''scoreboard'' doit savoir nà tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Pour comparer, avec un ROB, l'unité d'émission communique surtout avec le ROB, qui lui indique quelles instructions viennent de se terminer. Et éventuellement les unités de calcul, pour savoir lesquelles sont libres. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les dépendances ''Write After Write''===
Plus haut, j'ai sous-entendu que l’absence d'exceptions précises avait des effets largement au-delà des problèmes liés aux exceptions et à la prédiction de branchement. Le ROB remet en ordre les écritures, afin d'éviter tout phénomène d'inversion des écritures. Et l'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme.
Prenons deux instructions proches, qui écrivent dans le même registre. 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écises.
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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scorebaords'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si le ROB est présent, il est unique, car les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises et n'avaient pas de ROB.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
<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>
t6h3gud73qqa5w21kh76k5i85v4lm4l
775263
775262
2026-10-14T14:34:20Z
Mewtow
31375
/* Les dépendances Write After Read */
775263
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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'' était initialement utilisé pour désigner le ''scoreboard'' du CDC 6600, dont l'implémentation était très particulière, et qui aura droit à un chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui est 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, grâce au système de remise en ordre des écritures, à la fin du pipeline.
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient le ''scoreboard''. Le ''scorebaord'' détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre au ''scoreboard''. Le ''scoreboard'' recoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. Le ''scoreboard'' doit gérer ces dépendances de lui-même, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que le ''scoreboard'' doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 avec ''scoreboard'' sans exceptions précises. L'absence d’exceptions précises simplifie grandement le design du processeur : pas besoin de ROB, de tampon d'historique, de banc de registre futur, etc. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le ROB est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec un ROB pour gérer les exceptions précises.
Les processeurs avec un ''scoreboard'' et sans ROB sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
En absence d'exceptions précises, le ''scoreboard'' doit savoir nà tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Pour comparer, avec un ROB, l'unité d'émission communique surtout avec le ROB, qui lui indique quelles instructions viennent de se terminer. Et éventuellement les unités de calcul, pour savoir lesquelles sont libres. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les dépendances ''Write After Write''===
Plus haut, j'ai sous-entendu que l’absence d'exceptions précises avait des effets largement au-delà des problèmes liés aux exceptions et à la prédiction de branchement. Le ROB remet en ordre les écritures, afin d'éviter tout phénomène d'inversion des écritures. Et l'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme.
Prenons deux instructions proches, qui écrivent dans le même registre. 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écises.
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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scorebaords'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si le ROB est présent, il est unique, car les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises et n'avaient pas de ROB.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
<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>
8v3bh0a9g8zorwdjz5xoh46ksriv07y
775264
775263
2026-10-14T14:36:38Z
Mewtow
31375
775264
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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'' était initialement utilisé pour désigner le ''scoreboard'' du CDC 6600, dont l'implémentation était très particulière, et qui aura droit à un chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui est 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, grâce au système de remise en ordre des écritures, à la fin du pipeline.
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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 avec ''scoreboard'' sans exceptions précises. L'absence d’exceptions précises simplifie grandement le design du processeur : pas besoin de ROB, de tampon d'historique, de banc de registre futur, etc. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le ROB est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec un ROB pour gérer les exceptions précises.
Les processeurs avec un ''scoreboard'' et sans ROB sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
En absence d'exceptions précises, le ''scoreboard'' doit savoir nà tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Pour comparer, avec un ROB, l'unité d'émission communique surtout avec le ROB, qui lui indique quelles instructions viennent de se terminer. Et éventuellement les unités de calcul, pour savoir lesquelles sont libres. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les dépendances ''Write After Write''===
Plus haut, j'ai sous-entendu que l’absence d'exceptions précises avait des effets largement au-delà des problèmes liés aux exceptions et à la prédiction de branchement. Le ROB remet en ordre les écritures, afin d'éviter tout phénomène d'inversion des écritures. Et l'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme.
Prenons deux instructions proches, qui écrivent dans le même registre. 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écises.
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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scorebaords'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si le ROB est présent, il est unique, car les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises et n'avaient pas de ROB.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
<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>
4worhhbr4t4qjuyg5cglv1j69e1uzzh
775265
775264
2026-10-14T14:46:55Z
Mewtow
31375
/* Le tampon d’historique */
775265
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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'' était initialement utilisé pour désigner le ''scoreboard'' du CDC 6600, dont l'implémentation était très particulière, et qui aura droit à un chapitre dédié. Pour ce chapitre, un ''scoreboard'' est une unité d'émission, qui est 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, grâce au système de remise en ordre des écritures, à la fin du pipeline.
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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 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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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 avec ''scoreboard'' sans exceptions précises. L'absence d’exceptions précises simplifie grandement le design du processeur : pas besoin de ROB, de tampon d'historique, de banc de registre futur, etc. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le ROB est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec un ROB pour gérer les exceptions précises.
Les processeurs avec un ''scoreboard'' et sans ROB sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
En absence d'exceptions précises, le ''scoreboard'' doit savoir nà tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Pour comparer, avec un ROB, l'unité d'émission communique surtout avec le ROB, qui lui indique quelles instructions viennent de se terminer. Et éventuellement les unités de calcul, pour savoir lesquelles sont libres. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les dépendances ''Write After Write''===
Plus haut, j'ai sous-entendu que l’absence d'exceptions précises avait des effets largement au-delà des problèmes liés aux exceptions et à la prédiction de branchement. Le ROB remet en ordre les écritures, afin d'éviter tout phénomène d'inversion des écritures. Et l'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme.
Prenons deux instructions proches, qui écrivent dans le même registre. 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écises.
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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scorebaords'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si le ROB est présent, il est unique, car les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises et n'avaient pas de ROB.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
<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>
b3xskgrh5byouwrbgw8jzzbb5s8sgcm
775266
775265
2026-10-14T15:20:25Z
Mewtow
31375
775266
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances au niveau du ''scoreboard'', mais c'est très compliqué et l'implémentation dépend du pipeline étudié. En général, les dépendances WAR sont détectées en-dehors du ''scoreboard'', elles ne sont pas détectées à l'émission. Les écritures se faisant dans le désordre, détecter les dépendances WAR à l'émission demande une connaissance parfaite de l'état du pipeline, qui serait couteuse en circuits, pour des cas assez rares. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission.
La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire. L'idée est la suivante : l'étage de ''writeback'' fait quelques vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais cela signifie qu'il doit traiter des dépendances de données autres que les dépendances RAW : il doit traiter les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique. Les dépendances WAR sont notamment traitées à la fin du pipeline, dans l'étage de ''writeback'', comme on le verra dans la suite de cette section. En conséquence, le ''scoreboiard'' est plus une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===La gestion des exceptions précises===
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le système d'exceptions précises est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Les processeurs avec un ''scoreboard'' et sans exceptions précises sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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 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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
278wmpwdzw3bv3pmshn2x1n5agp7uqn
775267
775266
2026-10-14T15:30:10Z
Mewtow
31375
/* Les dépendances Write After Read */
775267
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais cela signifie qu'il doit traiter des dépendances de données autres que les dépendances RAW : il doit traiter les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique. Les dépendances WAR sont notamment traitées à la fin du pipeline, dans l'étage de ''writeback'', comme on le verra dans la suite de cette section. En conséquence, le ''scoreboiard'' est plus une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, etc. Le ''scoreboard'' est donc une structure centralisée, qui reçoit des informations de tout le pipeline. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===La gestion des exceptions précises===
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le système d'exceptions précises est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Les processeurs avec un ''scoreboard'' et sans exceptions précises sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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 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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
ejnmcubb690ijvwqweqxef3c5jtgumy
775268
775267
2026-10-14T15:32:35Z
Mewtow
31375
/* Le scoreboard : une unité centralisée qui contrôle le pipeline */
775268
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===La gestion des exceptions précises===
Les processeurs avec un ''scoreboard'' et des exceptions précises sont assez rares. La plupart se passent du système de remise en ordre complétement ! Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls, et nous dédierons une section entière à de tels processeurs.
L'absence d'exceptions précises est courante sur les processeurs avec un ''scoreboard''. La raison est qu'un ''scoreboard'' est utilisé pour implémenter une forme limitée d'exécution dans le désordre, qui est assez économe en circuits. Mais le système d'exceptions précises est lui assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Les processeurs avec un ''scoreboard'' et sans exceptions précises sont donc rares. Parmi les CPUs, ils sont presque inexistants, du fait de l'absence d'exceptions précises. Du fait de l’absence d'exceptions précises, ils doivent ajouter de quoi annuler les instructions en vol avant l'étage d'exécution, en remplacement du système d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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 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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
tmblgs1ohf3kq98fu7xwhot1bz0t4zz
775269
775268
2026-10-14T15:37:20Z
Mewtow
31375
/* La gestion des exceptions précises */
775269
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', 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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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 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.]]
Un défaut de cette technique est qu'elle gère certes les exceptions précises, mais pas les dépendances ''Write After Write''. Pour rappel, cela veut dire que les écritures dans des registres différents peuvent se faire dans le désordre, mais que les écritures dans un même registre doivent se faire dans l'ordre. L'unité d'émission doit gérer ces dépendances, en retardant les instructions problématiques.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
nh8yvr3mirnfrt4elt5ktue73eqfar3
775270
775269
2026-10-14T15:43:38Z
Mewtow
31375
/* La remise en ordre des écritures */
775270
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===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 de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===Les points 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
msti2t6qzol6yctvc8u61josbiyrsgs
775271
775270
2026-10-14T15:48:13Z
Mewtow
31375
/* Le tampon de réordonnancement */
775271
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===Les points 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
s6l5lzwiofa35ek18i0qni3qewxiu3q
775272
775271
2026-10-14T15:51:44Z
Mewtow
31375
/* Les points de contrôle de registre */
775272
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
195dwm6pdzv177jr6gs92wvdgfjrznv
775273
775272
2026-10-14T15:55:18Z
Mewtow
31375
/* Les points de contrôle de registre */
775273
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 annule 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
rse3l0kha90sxxffybe1j9qhvnb6o3r
775274
775273
2026-10-14T15:55:44Z
Mewtow
31375
/* Les points de contrôle de registre */
775274
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 annule 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 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
rv24f8zz8spsfer9b18nmntol3mo3gs
775275
775274
2026-10-14T15:56:11Z
Mewtow
31375
/* Le banc de registres futurs */
775275
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 annule 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 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
f7vqo9pwb7u25ltiq7ejjpiebgrfznn
775276
775275
2026-10-14T15:56:19Z
Mewtow
31375
/* Les points de contrôle de registre */
775276
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
43palfvk1hq6xnz9ntfddpzt4yg05if
775277
775276
2026-10-14T15:56:26Z
Mewtow
31375
/* Le tampon d’historique */
775277
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==La remise en ordre des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a 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 d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 annule 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.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
28j4s4a43s4hpbl3po2xp6urmvarjqg
775278
775277
2026-10-14T15:58:13Z
Mewtow
31375
/* La remise en ordre des écritures */
775278
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
k3c4sjfsqeynw0elw6wtcwl0zur7jh0
775279
775278
2026-10-14T15:58:38Z
Mewtow
31375
/* Les points de contrôle de registre */
775279
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Vous avez peut-être pensé à relier le ROB au réseau de contournement pour résoudre ce problème, mais nous en reparlerons.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
ny1tmnr6klm79lw7wzwsncg3q6juihf
775280
775279
2026-10-14T16:03:49Z
Mewtow
31375
775280
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. Formellement, il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres, mais j'en touche un mot maintenant.
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 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 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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
feksr0wobch0q25vb55590ggykdab7v
775281
775280
2026-10-14T17:04:32Z
Mewtow
31375
/* Le contournement avec un ROB */
775281
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===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.
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', en plus des classiques dépendances RAW. Mais l'avantage est que les instructions peuvent s'exécuter immédiatement si les dépendances le permettent. Le FF contient donc les dernières données calculées, ce qui fait que les opérandes sont lues dedans.
Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Avec un ROB, ce genre de choses est possible, mais cela aurait demandé de modifier le réseau de contournement, pour lire dans le ROB. Et cela vient avec son lot de complications, qu'on décrira dans le chapitre sur le renommage de registres.
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 est utilisé avec le renommage de registre, mais nous verrons cela dans le chapitre dédié.
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
n7pr3x74ejw3jdcd2vjdn3975vnmik5
775282
775281
2026-10-14T17:08:41Z
Mewtow
31375
/* Le banc de registres futurs */
775282
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
ou6ds3vc5enx0aani508jgml3l8y1xz
775283
775282
2026-10-14T17:09:18Z
Mewtow
31375
/* La optimisations liées au ROB */
775283
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
La technique peut aussi être comparée avec les points de contrôles des registres. 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.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
pkzsvfy1obzqcquhkq0nt5lzlv28vyc
775284
775283
2026-10-14T17:10:14Z
Mewtow
31375
/* Le banc de registres futurs */
775284
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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''. 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]]
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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 gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
pr02wf6achuyl383qdinwoqmcr2r05q
775285
775284
2026-10-14T17:12:24Z
Mewtow
31375
775285
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write aAfter Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
91mvufeiw5qq0da7kauxf8g4iyfumaz
775286
775285
2026-10-14T17:12:40Z
Mewtow
31375
775286
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique est la suivante :
* En premier lieu, il contient un registre de disponibilité, pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* En second lieu, il contient une table qui donne la disponibilité de chaque unité de calcul, afin de gérer les dépendances structurelles.
* Enfin, il contient une table des instruction, qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
pei63ibfvkiky34jkt947uyumr0j7n4
775287
775286
2026-10-14T17:14:15Z
Mewtow
31375
/* Le scoreboard : une unité centralisée qui contrôle le pipeline */
775287
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instruction''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
p62u7d0t6e3rj41172yqhbsrlhemxhb
775288
775287
2026-10-14T17:14:24Z
Mewtow
31375
/* Le scoreboard : une unité centralisée qui contrôle le pipeline */
775288
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Les ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
dxi6oenlp95clvk1uhaa5ugctypuexh
775289
775288
2026-10-14T17:14:38Z
Mewtow
31375
/* Les scoreboards décentralisés */
775289
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ROB==
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 ROB 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 ROB 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, 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>
evig6x2ytgzygtnx1swtjy3v94bph2k
775290
775289
2026-10-14T17:14:46Z
Mewtow
31375
/* La complétion dans le désordre */
775290
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, 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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
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 ROB 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 ROB 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, 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>
q0a03oc7orujb5cgwzk8eh3gr1l4g4p
775291
775290
2026-10-14T17:15:06Z
Mewtow
31375
/* Le commit multiple dans le ROB */
775291
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]]
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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleur solution que j'ai trouvé sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication...
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénoméne assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'éciture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparé avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fournit son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisations, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de ré-ordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieurs quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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.
{|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.
Par la suite, les résultats sont 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 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. L'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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire une opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les isntructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tord. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été çà la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
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 ROB 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 ROB 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, 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>
pnbv7fi3zb4m33lu3gbww03knf6gofh
775292
775291
2026-10-14T17:19:24Z
Mewtow
31375
775292
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précise nativement. Mais on peut rajouter le support des exceptions précises en ajoutant un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'utilise de ''scoreboard'', du fait de l'absence d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sous stéroïde, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 ne 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ésultat 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'instructions, 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édents.
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 ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
3wx0pzuo6hlufvd2tx7wcfw82bwqry8
775293
775292
2026-10-14T17:25:19Z
Mewtow
31375
/* Le scoreboard sans exceptions précises */
775293
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Aucun CPU moderne n'accepte de se passer d'exceptions précises. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 ne 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ésultat 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'instructions, 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édents.
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 ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
30zd6a2zfyz6zsdqzh6dmzer5wmv5rb
775294
775293
2026-10-14T17:26:06Z
Mewtow
31375
/* Le scoreboard sans exceptions précises */
775294
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente tardive des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 ne 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ésultat 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'instructions, 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édents.
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 ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
ca8ulrxln67v13sm10ilx3s739qrjry
775295
775294
2026-10-14T17:26:35Z
Mewtow
31375
/* La mise en attente tardive des écritures */
775295
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 ne 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ésultat 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'instructions, 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édents.
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 ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
kclyw239l8tha00p5omco1895h6eiws
775296
775295
2026-10-14T17:29:34Z
Mewtow
31375
/* Les défauts de cache non-bloquants */
775296
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les 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 ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 cache prenne plusieurs dizaines ou centaines de cycles d'horloges, 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. Les instructions en question ne quitteront pas le pipeline, elles seront mises en attente dans le ROB. L'enregistrement du résultat 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, ou dans un banc de registres futurs.
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'instructions, 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édents.
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 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.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
lwb3hhfe5hc7rvysyksk7zxi2v1a392
775297
775296
2026-10-14T17:31:44Z
Mewtow
31375
/* Les optimisations liées au ROB */
775297
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB, avec potentiellement un banc de registres futurs. Les autres techniques sont tellement peu utilisées que ça 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 cache prenne plusieurs dizaines ou centaines de cycles d'horloges, 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. Les instructions en question ne quitteront pas le pipeline, elles seront mises en attente dans le ROB. L'enregistrement du résultat 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, ou dans un banc de registres futurs.
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'instructions, 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édents.
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 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.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
gshj5z976768be2qhokhdj5wuewam63
775298
775297
2026-10-14T17:31:55Z
Mewtow
31375
/* Les optimisations liées au ROB */
775298
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB, avec potentiellement un banc de registres futurs. 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 cache prenne plusieurs dizaines ou centaines de cycles d'horloges, 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. Les instructions en question ne quitteront pas le pipeline, elles seront mises en attente dans le ROB. L'enregistrement du résultat 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, ou dans un banc de registres futurs.
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'instructions, 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édents.
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 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.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ROB 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 à tort 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 termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
ev1u30n3umfr1jqvjgtqe0lvtv25doe
775299
775298
2026-10-14T17:33:51Z
Mewtow
31375
/* L'émission anticipée des accès mémoire */
775299
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 pipelines 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... Et il y a d'autres problèmes possibles, appelés des dépendances ''Write After Write'', qu'on détaillera dans la suite du chapitre.
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écises séparé. 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 completion - in-order/out-of-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Mais avant tout chose, précisons comment nous allons étudier le sujet. Nous allons d'abord étudier un processeur où les écritures peuvent se faire dans le désordre, sans exceptions précises. Puis, nous rajouterons les exceptions précises par la suite. Faire ainsi est la meilleure solution que j'ai trouvée sur le plan pédagogique, pour expliquer convenablement le tampon d'historique, et le banc de registre futur.
==Le ''scoreboard'', sans exceptions précises==
L'absence d’exceptions précises simplifie grandement le design du processeur. Par contre, l'inversion de l'ordre des écritures pose des problèmes qui n'ont rien à voir avec les exceptions précises, comme on le verra plus bas. Et toute la difficulté est de les gérer. Pour simplifier, la plupart de ces problèmes sont gérés par une unité d'émission sous stéroïdes, appelées un '''''scoreboard'''''.
Et là, il faut faire quelques précisions terminologiques. Le terme ''scoreboard'' était initialement utilisé pour désigner le ''scoreboard'' du ''mainframe'' CDC 6600, dont l'implémentation était très particulière. Suffisamment particulière pour avoir droit à un chapitre dédié. Mais de nos jours, le terme ''scoreboard'' est utilisé un peu n'importe comment, chaque auteur mettant quelque chose de différent derrière. Il désigne souvent une unité d'émission ou quelque chose s'en approchant. Pour certains, dès qu'un circuit vérifie les dépendances RAW et structurelles, c'est un ''scoreboard''. Nous ne ferons pas cette erreur. Mais pour expliquer ce qu'est un ''scoreboard'', nous allons d'abord devoir parler des dépendances WAW et WAR.
===Les dépendances ''Write After Write''===
L'absence d'exceptions précises a des effets qui dépassent les exceptions matérielles et la prédiction de branchement. L'inversion de l'ordre des écritures n'impacte pas que les exceptions précises, mais peut casser complétement l'exécution d'un programme. Pour comprendre pourquoi, nous allons prendre un processeur sans exceptions précises et sans prédiction de branchements, où les écritures peuvent se faire dans le désordre. L'unité d'émission ne cherche pas à remettre les écritures dans l'ordre, elle n'a pas de registre de réservation.
Si les é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. Par exemple, prenons une multiplication suivie d'une addition, les deux enregistrant leur résultat dans le même registre. Une fois les deux instructions exécutées, on s'attend à ce que le registre contienne le résultat de l'addition. Mais si la multiplication prend 4/5 cycles, il y aura inversion de l'ordre des écritures. Une fois les deux instructions terminées, le registre contiendra le résultat de la multiplication.
En clair, quand deux écritures s'inversent, le registre est é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.
La gestion de ces dépendances est le fait du ''scoreboard'', quand il n'y a pas de système d'exception précises. 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éellement 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 ''registre de réservation'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''registre de réservation'' 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 ''registre de réservation'' 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épendances ''Write After Read''===
L'inversion de l'ordre des écritures cache un phénomène assez intuitif : si on ne remet pas en ordre les écritures, certaines écritures vont se faire de manière anticipée. Elles se feront en avance, bien avant ce qui se passerait sans pipeline. Et si une écriture peut passer avant une autre écriture, elle peut passer avant une lecture. Si l'écriture et la lecture se font dans des registres différents, cela n'a pas de conséquence, si ce n'est un gain en performance. Mais si la lecture et l'écriture se font dans le même registre, il y a un problème.
Les '''dépendances ''Write After Read''''' surviennent quand une écriture a lieu après une lecture dans le même registre. Dans ce cas, l'écriture doit se faire après la lecture, histoire de ne pas écraser la donnée avant qu'elle soit lue. De telles dépendances sont extrêmement rares, car les lectures tendent à se faire au début du pipeline et les écritures à la fin. Cependant, quand elles existent, elles doivent être gérées.
Il est possible de gérer ces dépendances à l'émission, mais c'est très compliqué. En général, les dépendances WAR sont détectées ne sont pas détectées à l'émission. L'auteur de ce wikilivre n'a pas connaissance d'un processeur qui détecte les dépendances WAR à l'émission. La solution utilisée gère ces dépendances à l'étage de ''writeback'', au moment où les écritures sont censées se faire.
L'idée est la suivante : l'étage de ''writeback'' fait des vérifications avant d'autoriser l'écriture. Non seulement il vérifie qu'aucune exception n'a eu lieu, mais il vérifie aussi les dépendances WAR. Pour cela, elle regarde la liste des registres en attente de lecture, à savoir les registres qu'une instruction en vol veut lire, mais qui n'ont pas encore été lus. Si le registre de destination est le même qu'un de ces registres en attente, une dépendance WAR survient. L'écriture est alors mise en attente, le temps que les instructions en vol progressent.
Un détail est que si une écriture est mise en attente, les écritures suivantes passent. Rappelez-vous, les écritures ne se font pas dans l'ordre. Si une écriture est bloquée, les écritures qui arrivent après elles n'ont aucune raison d'être bloquées à sa suite, ce n'est même pas certains qu'elles soient situées après dans l'ordre du programme. L'unité de ''writeback'' a donc de quoi mettre en attente des écritures, quelques registres tout au plus. Quand ces registres sont tous remplis, l'émission est bloquée pour éviter tout problème.
Pour détecter les dépendances, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire. Les registres en attente de lecture sont comparés avec le registre sortant du pipeline, grâce à plusieurs comparateurs. Les comparateurs en question ne font pas partie de l'unité de ''writeback'', ils sont dispersés dans le pipeline, et communiquent leur résultat au ''scoreboard''. Si une dépendance WAR est détectée, le ''scoreboard'' peut stopper l'émission des instructions, le temps que le pipeline se vide.
[[File:Détection des dépendances WAR.png|centre|vignette|upright=2.5|Détection des dépendances WAR]]
===Le ''scoreboard'' : une unité centralisée qui contrôle le pipeline===
Dans le chapitre précédent, l'unité d'émission, qui détectait les dépendances structurelles, les dépendances RAW, et remettait en ordre les écritures en retardant l'émission des instructions fautives. Un ''scoreboard'' ne remet pas en ordre les écritures, mais doit gérer les dépendances WAW et WAR. Et traiter ces dépendances est complexe, cela ne peut pas être fait dans une unité d'émission classique pour les dépendances WAR. En conséquence, le ''scoreboard'' est une unité qui surveille le pipeline et commande la progression des instructions dans celui-ci.
En absence d'exceptions précises, le ''scoreboard'' doit savoir à tout instant dans quel étage se situe chaque micro-opération en vol. Il n'a pas le choix : c'est nécessaire pour savoir si la micro-opération est terminée, si elle a fourni son résultat, si une exception a eu lieu, si elle a atteint l'étage ''writeback'' et que ses dépendances WAR ont été testées. Le ''scoreboard'' n'est donc pas qu'une simple unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.
[[File:Scoreboarding.png|centre|vignette|upright=2.5|Le ''scoreboard'' n'est pas qu'une unité d'émission, mais une structure centralisée qui contrôle tout le pipeline.]]
Un ''scoreboard'' contient plusieurs circuits, mais le contenu exact varie selon le ''scoreboard''. Une implémentation classique contient :
* Un '''registre de disponibilité''', pour gérer les dépendances RAW, et éventuellement les dépendances WAW.
* Une '''table de disponibilité de chaque unité de calcul''', afin de gérer les dépendances structurelles.
* De quoi gérer les dépendances WAR ;
* Enfin, il contient une '''table des instructions''', qui mémorise dans quel étage se situe chaque instruction.
Le registre de disponibilité est toujours présent, de même que la table pour gérer les dépendances structurelles. Cependant, il arrive que le ''scoreboard'' soit scindé en deux circuits distincts : un qui gère les dépendances structurelles, un autre qui gère les dépendances RAW. En clair, les deux tables sont donc séparées dans deux circuits distincts.
===Le ''scoreboard'' sans exceptions précises===
Les processeurs avec un ''scoreboard'' ne supportent pas les exceptions précises nativement. Mais on peut rajouter un système d'exceptions précises "autour du pipeline". Et nous allons voir comment faire dans la section suivante. Mais avant ça, précisons qu'une bonne partie des processeurs avec un ''scoreboard'' se passent d'exceptions précises. Le ''scoreboard'' du CDC 6600 et du CDC 7600 en sont les premiers exemples. Mais ils ne sont pas les seuls.
La raison est qu'un ''scoreboard'' est utilisé pour implémenter une optimisation appelée l'exécution dans le désordre. Le ''scoreboard'' implémente cette optimisation, d'une manière assez limitée, mais très économe en circuits. Or, le système d'exceptions précises est assez gourmand en circuits, ce qui ne colle pas avec cet objectif d'économie. Un concepteur de processeur qui a un budget en transistors limité préférera utiliser un ''scoreboard'' sans exceptions précises. A l'opposé, un budget en transistor conséquent permet d'utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, sans ''scoreboard'' et avec exceptions précises.
Si aucun CPU grand public n'accepte de se passer d'exceptions précises, ce n'est pas pareil pour les systèmes embarqués. Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, expurgé des instructions flottantes. Si j'en crois le manuel d'optimisation officiel, ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs ont été vendus sous dans du matériel réseau (routeurs, ''network processors'') et dans quelques''System On Chip''.
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Une bonne partie implémente un ''scoreboard'' amélioré, sans système d'exceptions précises. Les GPU ne permettent pas d'interrompre l'exécution d'un programme (un ''shader'') tant qu'il ne se termine pas de lui-même, sauf à RESET le processeur de ''shader''. Mais ils sont très particuliers et je préfère laisser leur étude à mon wikilivre sur les cartes graphiques.
==L'annulation tardive des écritures==
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 processeurs ''in-order'', avec le ''registre de réservation'', je ne reviens pas dessus. Mais il y a d'autres solutions. Elles peuvent se classer en deux types : celles avec un tampon de réordonnancement, celles sans. Nous allons d'abord voir celles sans.
===Le tampon d’historique===
Une première solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais peut revenir en arrière en cas de problème. Une écriture remplace la valeur d'un registre par une nouvelle valeur. Revenir en arrière implique de mémoriser ces valeurs antérieures quelque part, pour les restaurer en cas de problèmes. Pour cela, le processeur mémorise les valeurs écrasées dans un '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire mémorise la valeur écrasée par une instruction. Lorsqu'une instruction modifie un registre, le processeur empile une copie de l'ancienne valeur, dans le tampon d'historique. Pour annuler les modifications faites par des instructions exécutées à tort, 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 tampon d'historique est couplé à un ''scoreboard'', qui émet les instructions dans l'ordre. Mais les écritures se font dans le désordre, du moins pour les écritures se faisant dans des registres différents. Les écritures dans un même registre se font dans l'ordre, grâce au ''scoreboard''. Les dépendances WAR sont gérées à la fin du pipeline.
: La technique décrite a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'unité de renommage de registre remplace l'''history buffer''. Mais nous verrons cela dans le chapitre dédié.
===Les points 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écises, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle sauvegarde 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 correctes 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]]
===Les deux techniques reviennent en arrière===
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 annule 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 à tort 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 mise en attente des écritures==
Les techniques précédentes laissent les instructions écrire dans les registres, quitte à remettre les valeurs adéquates en cas d'exceptions. Mais il existe 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.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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]]
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour gérer les exceptions précises. L'idée est de remettre les écritures dans l'ordre à la fin du pipeline. 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. 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éordonnancement 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. Pour cela, on profite du fait que les instructions sont émises dans l'ordre. Lors de son émission, l'unité d'émission ajoute l'instruction au ROB. Les instructions finissent donc triées dans l'ordre d'émission, qui est l'ordre du programme. Il est aussi possible d'insérer les instructions en sortie des décodeurs. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction et l'unité d'émission bloque le pipeline. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un détail important : quand une instruction quitte le ROB, le ROB prévient l'unité d'émission. L'unité d'émission détecte les dépendances RAW, comme toute unité d'émission, il doit donc savoir quand un résultat est écrit dans les registres. Pour cela, le ROB envoie le numéro/nom de registre à l'unité d'émission. L'unité d'émission reçoit ce numéro de registre et met à jour le registre de disponibilité en fonction.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de réordonnancement]]
Le ROB mémorise toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, le résultat à enregistrer, l'indicateur d'exception, etc. De plus, il contient de quoi faire le lien entre un résultat et l'instruction qui l'a produite. Il en a besoin pour savoir où enregistrer les résultats d'une instruction. 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 associée à une instruction. Elle contient le ''program counter'' associé à l'instruction, un champ pour mémoriser le résultat, un ''bit de présence'', et toutes les informations nécessaires pour le ''writeback''. Le bit de présence indique si le résultat a été enregistré dans l'entrée. 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 nom de "bits de présence".
{|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.
Par la suite, les résultats sont 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 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. L'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 instructions 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.
Un détail très important : vu que les écritures sont remises dans l'ordre, le ''scoreboard'' n'a pas besoin de détecter les dépendances WAW, elles sont inexistantes avec un ROB. Pas besoin d'utiliser un ''scoreboard'', une unité d'émission classique suffit. Juste de quoi détecter les dépendances structurelles et RAW, pas plus. Par contre, le ROB ne permet pas d'utiliser une donnée tant qu'elle est dans le ROB. Alors qu'avec un tampon d'historique, les données écrites en avance peuvent être utilisées comme opérande immédiatement, dès qu'elles sont écrites. Avec un ROB, la donnée doit attendre dans le ROB et quitter celui-ci pour être utilisable comme opérande. Mias il y a une solution possible pour éviter ça : l'usage du contournement ! Voyons cela dans le détail.
===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. Et le contournement permet de corriger le problème précédent : il suffit de lire les opérandes dans le ROB.
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 implémentent le '''contournement dans le ROB''', ce qui fait qu'ils autorisent à lire l'opérande depuis le ROB. L'implémentation conserve un réseau d'interconnexions entre ALU pour gérer le contournement, mais ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de lire les opérandes dans le ROB directement.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Et c'est là que les problèmes apparaissent. Détecter les dépendances RAW demande de savoir si un résultat est dans le ROB, ou non. Et l'unité d'émission ne peut pas consulter le ROB à chaque cycle d'horloge. De plus, lire un opérande dans le ROB demande de savoir dans quelle entrée elle se situe. Ces problèmes peuvent être résolus dans du renommage de registre, mais nous ne verrons le renommage de registres que dans quelques chapitres. Heureusement pour nous, il existe une solution alternative, qui se passe de renommage de registres.
===Le banc de registres futurs===
Au lieu d'utiliser le contournement dans le ROB, il est possible d'utiliser une solution alternative. Elle utilise un ROB, couplé à 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 le banc de registres de l'''History Buffer''. 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 est couplé à un ROB, histoire de conserver un état valide en cas d'exception.
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
En temps normal, le FF est utilisé pour l'exécution des instructions. Le FF accepte les écritures dans le désordre, ce qui fait qu'il a le même défaut que l'HB, à savoir que l'unité d'émission doit gérer les dépendances ''Write After Write'', ce qui en fait un vrai ''scoreboard''. Notons qu'un avantage comparé au ROB est que les données écrites dans le désordre peuvent être immédiatement utilisées comme opérande, même si la donnée n'a pas été validée par le système d'exception précise. Les instructions peuvent s'exécuter immédiatement si les dépendances le permettent.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lus depuis le RFF, jusqu'à ce que le FF soit de nouveau utilisable. L'autre solution recopie le contenu du RRF dans le FF. Le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre, les mêmes que celles utilisées pour les points de contrôles de registres
: Le banc de registres futurs a une ressemblance avec une technique de renommage de registre, appelée le renommage à banc de registres renommés. Mais nous verrons cela dans le chapitre dédié.
==Les optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB, avec potentiellement un banc de registres futurs. 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 ''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 ''registre de réservation'' 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 ''registre de réservation'' 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 ''registre de réservation'' 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"
|+ registre de réservation, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0
|}
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===
Le ROB remet dans l'ordre les écritures dans les registres architecturaux, afin de gérer les branchements et exceptions. Il accumule pour cela les instructions exécutées en avance, pour retarder leurs écritures, afin qu'elles se fassent au bon moment. Mais de rares optimisations permettent de désactiver cette remise en ordre 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'''. Elles s'activent en absence de branchements et quand le processeur sait qu'aucune exception ne peut survenir. La remise en ordre des écritures est alors mise en pause et les instructions quittent le ROB en avance. Le résultat est que écritures se font dans le désordre, dans le ''retirement register file'', ou le banc de registres physique.
Un exemple est celui du processeur ARM Cortex 73, qui dispose d'un tel mécanisme. Il est activé quand une lecture fait un défaut de cache. Le défaut de cache peut parfois lever une exception matérielle, par exemple s'il entraine un défaut de page, ou une erreur de protection mémoire. En théorie, les instructions après la lecture doivent accumuler leurs résultats dans le ROB, en attendant que le défaut de cache soit traité. C'est seulement une fois le défaut de cache terminé que les instructions suivantes peuvent ''retire'', sortir du ROB.
L'optimisation du Cortex A73 est que le processeur détecte les exceptions en avance, en accédant rapidement à la TLB. Une fois que le processeur sait qu'aucune exception n'aura lieu, les instructions en attente peuvent ''retire'' immédiatement, et écrire dans les registres architecturaux. Et ce alors que le défaut de cache n'est pas terminée. Par contre, le mécanisme ne s'applique qu'en absence de branchements, pour ne pas autoriser des écritures dans les registres par des instructions peut-être exécutées à tort. Le mécanisme est aussi désactivé sir la lecture est exécutée de manière spéculative, si son exécution l'a été à la suite d'une prédiction de branchement non-résolue.
L'idée est donc 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.
L'avantage est que les instructions quittent le ROB plus rapidement que prévu, ce qui utilise sa capacité au mieux. 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 permettent 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 ''scoreboards'' décentralisés===
Sur les processeurs qui ont un pipeline flottant séparé du pipeline entier/général, il est possible d'utiliser deux ''scoreboards'' : un pour le pipeline entier, un autre pour le pipeline flottant. Si un système d'exceptions précise est présent, il est unique. Les exceptions précises demandent que toutes les écritures se fassent dans l'ordre, séparer écritures entières et flottantes ne marche pas. On ne peut pas se permettre d'avoir des écritures entières qui prennent de l'avance sur les écritures flottantes. En pratique, les processeurs utilisant cette technique n'utilisaient pas d'exceptions précises.
Les instructions de copie entre registres entier et flottants agissent comme des points de synchronisation globales. Quand une telle instruction a lieu, les deux pipelines sont bloqués, on vide bien les deux pipelines, histoire de que toutes les écritures se fassent. Il est possible d’optimiser le tout, pour ne tenir compte que des registres copiés, mais le cout en circuit n'est pas pertinent comparé à un ''scoreboard'' unique.
[[File:Processeur avec des scoreboards séparés pour les pipelines entier et flottant.png|centre|vignette|upright=2|Processeur avec des scoreboards séparés pour les pipelines entier et flottant.]]
Cependant, une telle technique est rarement utilisée, pour une raison simple : le pipeline flottant a des instructions assez lentes, comparé au pipeline entier qui a beaucoup d'instructions en un cycle. L'usage d'un ''scoreboard'' n'est pas très pertinent si toutes les instructions se font en un cycle. Autant rester sur de l'émission ''in-order'' basique pour le pipeline entier. Les instructions flottantes, quant à elles, profitent plus de l'exécution dans le désordre. Elles mixent des additions assez rapides et des multiplications plus lentes, l'inversion de l'ordre des écritures est bien plus un problème pour elles.
Le processeur MIPS R4000 utilisait cette technique : un pipeline entier ''in-order'', mais un pipeline flottant avec un ''scoreboard''. Il utilisait, pour rappel, un coprocesseur flottant intégré dans le CPU, qui était assez séparé du CPU. Le coprocesseur partageait l'unité de chargement, mais avait ses propres décodeur, et tout ce qui va avec. Cette séparation rendait facile à implémenter un pipeline ''in-order'' entier, séparé du pipeline flottant à exécution dans le désordre.
==Les défauts de cache avec un ROB==
Les processeurs avec un ROB peuvent implémenter diverses optimisations, l'une d'entre elles é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 ROB 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 cache prenne plusieurs dizaines ou centaines de cycles d'horloges, 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. Les instructions en question ne quitteront pas le pipeline, elles seront mises en attente dans le ROB. L'enregistrement du résultat 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, ou dans un banc de registres futurs.
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'instructions, 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édents.
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 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.
===L'émission anticipée des accès mémoire===
Non seulement les processeurs avec un ROB 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 à tort 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 telle technique demande que le processeur ait un ''scoreboard'', qui soit de plus capable de bloquer une instruction en entrée de l'unité de calcul. Une simple unité d'émission ne suffit pas.
Annuler et ré-exécuter des instructions émises à tort est assez lourd en termes 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 performances assez limités.
Une dernière solution annule uniquement les instructions lecture-dépendantes émises à tort. 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, 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 manquant 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 lus 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 bons 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>
ikm79vlalvc2pnvdtnmke8eq1nqhqv8
Fonctionnement d'un ordinateur/Le scoreboarding et l'algorithme de Tomasulo
0
65891
775258
773792
2026-10-14T13:55:12Z
Mewtow
31375
/* Le renommage de registres dans les stations de réservation */
775258
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir deux techniques d'exécution dans le désordre un peu à part. Il s'agit de deux techniques historiquesn utilisées sur des ordinateurs très anciens, qui portent les noms de ''scoreboarding'' et d'algorithme de Tomasulo. Les deux ne sont plus utilisés aujourd'hui, pour diverses raisons, la principale étant qu'elles ne gèrent pas les exceptions précises. En clair, impossible de les utiliser en même temps que la prédiction de branchement ou d'autres optimisations.
Elles ne gèrent pas les exceptions précises, car elles utilisent des formes de renommage de registre assez originale. L'algorithme de Tomasulo renomme les registres dans les stations de réservation, chose qu'on n'a volontairement pas abordé dans le chapitre sur le renommage de registre. Le ''scoreboarding'' utilise quant à lui une technique similaire, mais bien plus difficile à classer, qui est clairement idiosyncratique. Il est possible de les modifier pour leur adjoindre un ROB, mais le résultat ressemble beaucoup aux techniques de renommage dans le ROB.
==L'algorithme de Tomasulo et les stations de réservation==
L''''algorithme de Tomasulo''' est un algorithme assez compliqué à expliquer, car il mélange exécution dans le désordre, contournement et renommage de registre implicite en un seul algorithme. L'algorithme de Tosmasulo a été implémenté une première fois sur les ordinateurs IBM 360/91, et quelques processeurs ont utilisé une variante de celui-ci. Beaucoup de processeurs modernes se sont sont inspiré de cet algorithme pour leur design. Il est antérieur au ''scoreboarding'' du CDC 6600, qui avait des fonctionnalités similaires, mais nous allons le voir avant, afin de mieux faire ressortir les ressemblances entre les deux algorithmes.
Avec l'algorithme de Tomasulo, le renommage de registre se fait dans les stations de réservation. Il s'agit là d'une forme de renommage de registre assez originale, qui n'est plus utilisée de nos jours. Et la raison est qu'elle n'est pas compatible avec les exceptions précises. L'algorithme original de Tomasulo n'avait pas de ROB et ne gérait pas du tout les exceptions précises. Il est possible d'ajouter un ROB à l'algorithme, mais on retombe alors sur une forme de renommage dans le ROB.
===Les stations de réservation===
L'algorithme de Tosmasulo a été utilisé tel quel sur la FPU de l'IBM 360 modèle 90, et uniquement celle-ci. Les ALU entière étaient séparées de la FPU et n'étaient pas concerné par tout ce qui va suivre Le processeur n'utilisait l'exécution dans le désordre que pour sa FPU, pas le "pipeline" entier/mémoire. La FPU en question avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes.
Les trois unités avaient chacune leur propre '''station de réservation'''. Pour rappel, ce terme décrit des ''schedulers'' qui ont deux particularités :
* Ils mémorisent non seulement les µops en attente, mais aussi leurs opérandes.
* Le ''scheduler'' est décentralisé au point d'utiliser un ''scheduler'' par unité de calcul.
Dans son acceptation la plus stricte, le terme station de réservation est réservé au cas où il y a une station de réservation par unité de calcul. Et c'est le cas avec l'algorithme de Tomasulo initial, qui a été utilisé pour la première fois sur un processeur avec trois stations de réservation : une pour le circuit multiplieur/diviseur flottant, une pour l'additionneur flottant, et une pour l'unité LOAD/STORE.
[[File:Tomasulo Architecture.png|centre|vignette|upright=2.5|Algorithme de Tomasulo originel, de la FPU de l'IBM 360.]]
Pour rappel, les stations de réservation impliquent une séparation entre ''dispatch'' et ''schedule''. En sortie de l'unité de décodage, les µops sont renommées puis envoyées à la station de réservation adéquate lors d'une étape de ''dispatch''. Elles attendent alors leur tour dans les stations de réservation, puis sont envoyées dans l'unité de calcul lors del'étape de ''schedule''.
Les stations de réservation mémorisent les µops en attente d'exécution, ainsi que leurs opérandes. Lors du ''dispatch'', l'unité de ''dispatch'' vérifie quelles opérandes sont dans les registres. Les opérandes déjà disponibles sont alors lues depuis les registres et mémorisées dans la station de réservation. Les autres opérandes, pas encore disponibles au moment de l'''issue'', seront fournies plus tard par le système de contournement. Les opérandes ont donc deux sources : registres, contournement. Pour rappel, il s'agit d'une technique dite de '''lecture avant émission''', qui lit les registres d'une µop avant de l'émettre.
Les opérandes disponibles à l'émission, lues depuis les registres, sont directement enregistrées dans la station de réservation. Leur bit de présence est mis à 1, il n'y a pas de renommage de registres, car il n'y en a pas besoin pour ces opérandes. Les opérandes non-disponibles seront envoyées via contournement plus tard et le renommage de registre doit être utilisé. L'opérande est associée à l'identifiant de résultat qui permet de savoir quand elle sera calculée.
===Le bus de contournement===
Les résultats d'une µop sont non seulement envoyés les registres, mais ils sont aussi envoyés vers les stations de réservation, via un réseau de contournement un peu particulier. La sortie de chaque unité de calcul est connectée à toutes les stations de réservation, via un bus appelé le ''bus commun d’ordonnancement de la mémoire'' (''common memory-ordering bus''), que nous nommerons dorénavant ''bus de contournement''.
[[File:Algorithme de Tomasulo.png|centre|vignette|upright=2|Algorithme de Tomasulo]]
Le '''bus de contournement''' n'est pas un réseau de contournement classique. Il est plus simple et utiliser moins de fils. Le fait que ce soit un bus, et non un vrai réseau complet, fait qu'une seule unité de calcul peut envoyer des données sur ce bus. Si plusieurs unités de calcul veulent transmettre leurs résultats sur ce bus, l'une d'entre elle est choisie et les autres doivent mettre en attente leurs résultats. La mise en attente se fait grâce à des registres situés en sortie des ALU, appelés des ''producers''. L'implémentation demande de bloquer des opérations dans l'ALU si le contournement doit être mis en attente, ce qui bloque tout le pipeline en cas de problème.
[[File:Producteurs en sortie d'une ALU.jpg|centre|vignette|upright=2|Producteurs en sortie d'une ALU.]]
Le bus de contournement est assez spécifique à l'algorithme de Tomasulo, dans le sens où c'est un des rares articles académique à utiliser un tel bus de contournement.
===Le renommage de registres dans les stations de réservation===
L'unité d'émission attribue à chaque résultat de calcul un '''identifiant de résultat'''. L'identifiant est une sorte de numéro de registre virtuel, qui est transmis sur le bus de contournement. L'identifiant est propagé comme tout signal de commande, il suit la µop sur son trajet dans le pipeline. Quand l'unité de calcul a fini son calcul, l'identifiant est envoyé avec le résultat sur le bus de contournement. Les stations de réservation de destination, qui ont cet identifiant dans leur entrée, enregistrent le résultat dans le champ adéquat.
Pour attribuer un identifiant de résultat, l'unité d'émission contient un circuit équivalent à une ''register alis table''.
Les entrées des stations de réservation sont structurées en plusieurs champs. Le premier mémorise l'opcode de l'instruction/µop. Il y a aussi un champ pour l'identifiant de résultat. Il y a aussi deux champs, un par opérande. Il peut contenir deux choses : l'opérande elle-même, l'identifiant de résultat associé. Pour faire la différence, les deux champs ont chacun un bit de présence, qui indique si l'opérande est présente dans le champ ou non (auquel cas, c'est un identifiant).
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Vous vous demandez certainement où sont les registres virtuels avec cette organisation. Et bien il ne s'agit ni plus ni moins que des stations de réservation. Et plus précisément les champs pour les opérandes. L'identifiant de résultat est l'équivalent d'un nom de registre virtuel. D'ailleurs, une fois la µop émise, il n'y a pas de noms de registre physique dans les stations de réservation, seulement des identifiants de résultat. En somme, le renommage de registre est pris en charge par les stations de réservation, et par le système de contournement.
==Le ''scoreboarding'' du CDC 6600==
L'implémentation du ''scoreboard'' sur le CDC 6600/7600 est appelée dans la suite du cours : le '''''scoreboarding'''''. La description que je vais donner du ''scoreboard'' est très différente de celle disponible dans les livres d'architecture des ordinateurs. La plupart des manuels, surtout les textbooks américains, donnent une description très haut niveau, très algorithmique du ''scoreboarding'', qui ne sera pas abordée.
À la place, je vais donner une description alternative, différente autant sur l'approche que le fond.
Le CDC 6600 n'existe plus, tous les exemplaires ont depuis longtemps été démontés, aussi il ne reste que des sources historiques pour savoir comment fonctionnait son ''scoreboard''. Heureusement, il existe un livre qui décrit dans les moindres détails cette architecture, écrit par un des concepteurs de l'ordinateur : [https://archive.computerhistory.org/resources/text/CDC/cdc.6600.thornton.design_of_a_computer_the_control_data_6600.1970.102630394.pdf Design of a computer : the CDC 6600]. Ce qui va suivre est majoritairement ité de cette source.
===Le chemin de données du CDC 6600 et sa relation avec le ''scoreboard''===
Le pipeline d'un processeur avec ''scoreboarding'' ressemble à celui du schéma suivant. Pour résumer, le chemin de donnée forme un pipeline à trois étages : lecture des registres, exécution dans l'ALU, écriture dans les registres. Les opérations à effectuer son envoyées aux ALUs, et elles attendent leurs opérandes dedans.
[[File:Scoreboard avec exceptions imprécises.png|centre|vignette|upright=2|Scoreboard avec exceptions imprécises]]
La séparation en trois étages fait qu'il y a des registres entre chaque étages. Les opérandes sont chargées dans des registres en amont de l'ALU, et le résultat est enregistré temporairement dans un registre en sortie de l'ALU. Nous les appellerons : '''registres opérandes''' et '''registre résultat'''. Entre les deux, l'ALU effectue une opération quand les opérandes sont disponibles dans les registres opérandes, et maintien le résultat dans le registre résultat si besoin. Il y a aussi un autre registre qui mémorise l'opcode de l'opération, lors de l'attente des opérandes, mais aussi lors de l'exécution. Nous l'omettrons dans ce qui suit, car il s'agit d'un registre de pipeline classique.
Les registres opérande et résultat n'ont pas besoin d'être très complexes vu qu'ils servent juste à maintenir une donnée. Les registres ne sont pas connectés à l'horloge, les bascules ne sont pas synchrones, ils sont fabriqués à partir de simples ''latches'', de simples bascules D non-synchrones. Et il en est de même pour le registre d'opcode. Le ''scoreboard'' commande le signal Enable qui autorise les écritures dedans, rien de plus. Le pipeline est donc le suivant :
[[File:Pipeline d'un processeur utilisant le scoreboarding.png|centre|vignette|upright=2|Pipeline d'un processeur utilisant le scoreboarding]]
Les registres opérandes sont chacun associé à un bit READ FLAG qui précise si l'opérande du calcul est disponible. Les bits READ FLAG sont mis à jour par un mécanisme assez complexe, qui sera vu dans la suite. Le ''scoreboard'' gère les transferts entre les registres opérande/résultat et le reste du processeur. Il génère deux signaux de 1 bit nommés GO READ et GO STORE/GO WRITE. Le signal GO READ déclenche la copie d'un registre architectural dans un registre opérande. Le signal GO WRITE déclenche la copie du registre résultat dans le registre architectural adéquat.
Pour effectuer une lecture ou une écriture, les noms des registres adéquats sont mémorisés dans deux registres notés Fi, Fj, et Fk. Fi mémorise le registre de destination de l'opération, Fj et Fk mémorisent les noms/numéros des registres opérandes. Les deux registres Qj et Qk sont utilisés pour la détection des opérandes disponibles, nous en reparlerons plus tard. Les registres Fi, FJ et Fk sont dans le ''scoreboard'', et ils sont envoyés sur les entrées d'adresse du banc de registre suivant les besoins, suivant qu'on fasse une lecture ou une écriture. La lecture et écriture des registres donc totalement le fait du ''scoreboard'', qui commande le banc de registres, comme le ferait un séquenceur normal.
[[File:CDC 6600 datapath control.png|centre|vignette|upright=2|CDC 6600 datapath control]]
Notez que les registres opérandes, couplés au registre d'opcode, peuvent être vus comme une station de réservation simplifiée, sans gestion du contournement. La différence principale avec l'algorithme de Tomasulo est que les champs pour les identifiants de résultat ont disparus, et que les bits de disponibilité des opérandes READ FLAG sont en réalité intégré au ''scoreboard'', comme on le verra plus tard, avec une gestion de la disponibilité des opérandes totalement différente. De plus, il n'y a qu'une seule station de réservation par ALU, donc un risque plus grand de dépendances structurelles. On ne peut mettre en attente qu'une seule µop par ALU, avec le ''scoreboard''.
===Les désignateurs utilisés par le ''scoreboard''===
Les registres opérandes et résultat ne sont pas des registres architecturaux, mais ce ne sont pas des registres de pipeline non plus. Il est possible de faire l'analogie avec une station de réservation, mais l'analogie est limitée pour une raison simple : le ''scoreboard'' attend que les deux opérandes sont disponibles pour déclencher la lecture avec le signal GO READ. En théorie, il est tout à fait possible de lire un registre opérande à la fois, dès que l'opérande est disponible, mais le ''scoreboard'' ne faisait pas comme ça. De plus, les opérandes ne sont pas vraiment transmises via un réseau de contournement depuis la sortie de l'ALU.
Cependant, une rumeur veut que le processeur implémente un système de contournement, qui passe...par l'intermédiaire des registres. Paradoxal, mais qui s'explique pourtant très bien quand on connait le fonctionnement des registres utilisés. Le processeur utilisait une ancienne technologie de transistors, appelée ECL. Beaucoup de bancs de registres fabriqués avec cette technologie avaient une propriété intéressante : toute donnée écrite sur le port d'écriture était immédiatement disponible sur le port de lecture. Plus précisément, l'écriture se faisait sur front montant et la lecture sur front descendant. En faisant cela, une opérande pouvait être écrite dans les registres architecturaux, mais pourtant copiée dans les registres opérandes au même cycle. Le tout fonctionne presque comme un système de contournement, avec cependant quelques limitations ! Avec un vrai système de contournement, l'implémentation est beaucoup plus puissante.
Quant à savoir si les registres opérandes sont nommés, c'est un peu compliqué et la réponse serait qu'ils sont nommés de manière implicite. Il est d'ailleurs débattable de savoir si le CDC 6600/7600 implémente le renommage de registres... Le point de discorde est lié au fait que le ''scoreboard'' attribue un numéro à chaque unité de calcul, appelé le '''désignateur Q'''. Et ces identifiants sont utilisés pour la lecture des opérandes et leur copie dans les registres, ils se comportent comme un nom de registre virtuel.
Pour simplifier les explications, nous allons partir du principe que les désignateurs sont encodés en représentation unaire. Pour rappel, la représentation unaire représente un nombre sur plusieurs bits, dont un seul est à 1. Un zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0010, etc.
{|class="wikitable"
|+ Désignateur d'ALU
|-
! ALU ADD !! ALU MUL !! ALU DIV !! ALU SUB !! ALU CMP !! ALU MOV
|-
| 0 || 0 || 1 || 0 || 0 || 0
|}
La réalité est que les désignateurs sont présents à plusieurs endroits et sont soit codés en unaires, soit codés en binaire, avec des traductions fréquentes entre les deux. Ils sont par exemple traduits du binaire vers l'unaire lors de la génération des signaux READ FLAG. Les traductions inverses sont aussi fréquentes. Les traductions se basent sur des décodeurs/encodeurs un peu modifiés.
L'avantage de la représentation unaire est que les comparaisons sont légèrement plus simples, les comparateurs économisent quelques portes logiques. De plus, elle s'adapte assez bien avec le fait que chaque unité de calcul précise si elle est disponible ou non, ce qui deviendra plus clair dans la suite.
===Les réservations et la table de réservation===
Le ''scoreboard'' du CDC 6600 contient une table de '''réservation''', qui permet de réserver une unité de calcul pour une µop à émettre. La table de réservation est composée d'entrées, une par unité de calcul, qui peut mémoriser une µop. Une entrée mémorise pour chaque unité de calcul :
* l'opcode de l'instruction à exécuter dessus ;
* les noms/numéros des registres opérandes et destination ;
* les désignateurs Q des unités de calcul qui fourniront les opérandes en résultat ;
* deux bits READ FLAG qui disent si l'opérande associé est disponible.
: Dans le livre utilisé comme source, il n'est pas clair si les noms des registres opérandes/résultat sont placés dans l'unité de calcul ou dans le ''scoreboard''. Les explications semblent indiquer que c'est juste à côté de l'ALU, mais les schémas se contredisent là-dessus. Dans la suite, je pars du principe qu'ils sont dans le ''scoreboard'', ce qui aurait bien plus de sens.
Le ''scoreboard'' consulte la table de réservation pour émettre une µop. Avant d’émettre une µop, elle regarde si l'entrée est libre ou déjà occupée par une µop. Pour cela, chaque entrée est associée à un bit BUSY qui indique si l'entrée est vide ou occupée. Vu qu'il y a une entrée par unité de calcul, attribuer une entrée à une µop est asszz simple : chaque µop va dans une entrée bien précise, il suffit de vérifier celle-ci. Le bit BUSY est mis à 1 quand une µop est émise, ajoutée dans l'entrée, mis à zéro quand elle est terminée. La libération d'une entrée se fait quand le signal GO WRITE est émis, le signal RELEASE ne suffit pas.
La réservation permet de réserver une ALU pour une opération. La µop est alors envoyée à l'ALU, où elle attend que ses opérandes soient disponibles. Une µop attend son tour et n'est émise qu'une fois que les deux bits READ FLAGS sont à 1. La conséquence est qu'une µop reste dans l'unité de calcul tant qu'elle a des dépendances RAW. Et seules les dépendances RAW, où une instruction pour opérande le résultat d'une instruction précédente, impliquent un blocage du pipeline et des bulles de pipeline. Les dépendances WAW ou WAR sont traitées autrement.
La table de réservation ne stocke pas les opérandes, ce qui fait qu'on peut la voir comme un ''scheduler'' à une entrée, ou encore comme une station de réservation castrée. Un détail très important est que ce système de réservation met en attente une µop par ALU. Vu que toutes les ALUs du CDC 6600 sont très différentes, l'attribution d'une µop à une ALU est trivial. Par contre, si une µop veut utiliser une ALU réservée, elle ne peut pas et il faut bloquer le pipeline. Le système de réservation est donc une cause de dépendances structurelles qui bloquent l'émission des µops dans de nombreux cas. Ce ne serait pas le cas si on pouvait placer plusieurs réservations par unité de calcul, mais passons.
Pour une dépendance WAR, la µop est émise, elle s'exécute, mais son résultat est maintenu dans le registre résultat le temps que ces dépendances soient résolues. Pour le dire autrement, Les dépendances WAR font que les µops sont retenues dans l'étage d'enregistrement des données, un peu comme sur un processeur à pipeline multicycle (de longueur fixe). Sauf que les pipeline multicycle, il y aurait plusieurs registres d'échelonnements qui se suivent, alors qu'on n'en a qu'un ici, ce qui fait que l'unité de calcul est bloquée si l'écriture est retardée. En théorie, les dépendances WAW devraient être traitées avec un mécanisme similaire, mais le processeur détectait ces dépendances à l'émission et bloquait l'émission si un registre lu était accédé en lecture.
[[File:Enregistrement des données dans les registres avec un pipeline avec scoreboarding.png|centre|vignette|upright=2|Enregistrement des données dans les registres avec un pipeline avec scoreboarding]]
===La Q-table : l'unité de renommage de registres===
Quand une µop est émise, elle réserve une unité de calcul. Mais il faut pour cela remplir les champs pour les désignateurs d'ALU, même si on voit mal à quoi ils servent pour le moment. Il se trouve que les registres opérandes contiennent une valeur, qui est le résultat d'une µop soit déjà passée par une ALU, soit en cours d'exécution dans une ALU. Le désignateur pour un opérande est donc le désignateur de cette ALU. Reste à faire la correspondance entre un résultat/registre et l'ALU qui va le calculer ou l'a déjà calculé. Pour cela, le ''scoreboard'' mémorise la correspondance entre un registre/résultat et une unité de calcul dans une mémoire cache appelée la '''''Q-table'''''. Pour comprendre à quoi elle sert, prenons deux instructions consécutives, dont la seconde utilise le résultat de la première.
La première instruction est lancée dans une unité de calcul enregistre son résultat dans un registre de destination. Le ''scoreboard'' mémorise alors dans la ''Q-table'' que le registre de destination est associé à telle unité de calcul, elle mémorise une correspondance entre le désignateur de l'ALU et le registre de destination. Par la suite, la seconde instruction est émise. Le ''scoreboard'' interroge la ''Q-table'' pour les deux opérandes de l'instruction, pour récupérer les deux désignateurs de l'ALU associés. Ils sont envoyés au banc de registres pour gérer la lecture des opérandes.
Pour résumer, le ''scoreboard'' effectue les actions suivantes lors de l'émission.
* Premièrement, il détermine si l'unité de calcul est disponible ou non, en utilisant le bit de disponibilité BUSY.
* Deuxièmement, il mémorise les numéros de registres opérande et résultat pour commander la lecture des opérandes.
* Troisièmement, il traduit les numéros de registres opérande en identifiants d'ALU.
* Quatrièmement, il mémorise dans une ''Q-table'' la relation entre le registre de destination et l'unité de calcul choisie à l'étape 1.
La troisième étape permet de faire le lien entre un résultat calculé mais pas encore enregistré dans les registres, et un registre opérande non-nommé. Le lien guide le chargement des opérandes et donc le système de contournement passant par des registres (ce qui est du renommage de registre implicite).
===La gestion des résultats : les signaux RELEASE et GOWRITE===
Une fois la µop exécutée, son résultat est enregistré dans le registre résultat. Une fois le résultat dans le registre résultat, l'unité de calcul envoie un signal REQUEST au ''scoreboard'' pour savoir si la donnée peut être enregistrée dans les registres architecturaux. L'ensemble des signaux REQUEST est regroupé dans un vecteur de bit, une sorte de nombre, dont chaque bit indique quelle unité veut écrire un résultat. L'ensemble des signaux RELEASE forment un nombre encodé en unaire qui indique quelle ALU a fournit le résultat. Là, le ''scoreboard'' effectue plusieurs opérations successives sur ce nombre, en appliquant plusieurs masques.
Le premier masque appliqué vérifie pour chaque ALU si elle a le droit d'écrire son résultat en mémoire, sur la base des dépendances WAW et WAR. En cas de dépendances WAR ou WAW, l'écriture du résultat est retardé, ce qui fait que l'unité de calcul n'est pas considérée, son signal RELEASE est ignoré, il est masqué. Le masque qui permet cela est appelé le '''masque READ PENDING'''.
Le second masque est un circuit de priorité qui sélectionne un bit seulement. Il est en effet possible que plusieurs unités de calcul fournissent un résultat en même temps. Ou encore que l'une ait son écriture retardée et ait un signal RELEASE à 1 depuis plusieurs cycles. Toujours est-il qu'on ne peut écrire qu'un résultat à la fois, ce qui fait qu'il faut sélectionner une unité de calcul. Pour cela, un circuit de priorité sélectionne un seul signal RELEASE à 1 et met les autres à zéro. C'est le second masque.
{|class="wikitable"
|+ Traitement des signaux REQUEST
|-
! colspan="10 | Signaux REQUEST en sortie des ALUs
|-
| 0 || 1 || 0 || 1 || 1 || 1 || 0 || 0 || 0 || 0
|-
! colspan="10 | Application du masque READ PENDING pour les dépendances WAW/WAR
|-
| 0 || 0 || 0 || 1 || 1 || 0 || 0 || 0 || 0 || 0
|-
! colspan="10 | Sélection par un circuit de priorité
|-
| 0 || 0 || 0 || 1 || 0 || 0 || 0 || 0 || 0 || 0
|}
Après l'application du second masque, il ne reste qu'un seul bit à un, qui indique quelle ALU a été sélectionnée pour l'écriture. Il se trouve que l'ensemble des bits forme un désignateur d'ALU, encodé en représentation unaire, en représentation ''one-hot''. Pour rappel, cela veut dire que seul un bit dans le nombre est à 1 et ce bit indique la valeur encodée. Si le bit numéro X est à 1, alors la valeur encodée vaut X. L'usage d'une représentation unaire/''one-hot'' fait que l'on a juste à concaténer les signaux REQUEST et appliquer des masques pour obtenir le désignateur adéquat, pas besoin d'encodeur compliqués, ni de circuits de sélection complexes. Le désignateur résultat est appelé '''désignateur d'ALU choisie'''.
Une fois ce désignateur calculé, le ''désignateur d'ALU choisie'' est envoyé à la table de réservation, afin de récupérer le nom/numéro du registre résultat associé. Le ''scoreboard'' commande le port d'écriture du banc de registre, avec un signal GOWRITE et le nom/numéro du registre de destination récupéré. Le signal RELEASE est alors remis à zéro. Reste à générer le signal GO WRITE et faire les actions adéquates.
Mais il reste deux opérations de maintenance pour terminer l'écriture. La première est de remettre en ordre la Q-table. L'entrée qui liait l'unité de calcul au registre de destination doit être remise à zéro. Le résultat ayant été écrit, il n'est plus associé à l'unité de calcul, le lien entre les deux dans la Q-table est donc effacé. La seconde est de gérer la disponibilité des opérandes. Pour cela, le résultat après application des deux masques est aussi utilisé pour détecter la disponibilité des opérandes, pour mettre à 1 le bit READ FLAG adéquat. Là encore, c'est la table de réservation qui s'en charge.
===La détection des opérandes disponibles : la génération des READ FLAG et du signal GOREAD===
Le désignateur d'ALU choise est envoyé à la table de réservation pour récupérer le nom de registre destination, mais aussi aux champs opérandes. Dans chaque entrée, il comparé aux deux désignateurs Qj et Qk, associés chacun à une opérande. Pour rappel, les désignateurs Qj et Qk sont des désignateurs d'unité de calcul, eux aussi encodés en unaire. S'il y a ''match'', alors l'opérande est disponible. Le comparateur génère un signal qui met à 1 le bit de disponibilité READ FLAG (i pour le numéro de l'unité de calcul), le bit est dans une bascule pour ne pas être effacé au cycle suivant.
Le ''scoreboard'' contient donc une matrice de comparaison, qui compare le résultat de l'étape précédente avec tous les désignateurs associés à une opérande. L'avantage d'encoder les désignateurs en unaire fait que la comparaison est plus simple : elle se constitue uniquement de portes ET, pas besoin de portes XOR comme on en aurait eu besoin en utilisant des désignateurs en binaire. De plus, le calcul du désignateur à partir des signaux REQUEST est trivial, pas besoin de passer par un encodeur.
[[File:Détection des dépendances CDC 6600.png|centre|vignette|upright=2|Détection des dépendances RAW CDC 6600]]
Quand les deux bits READ FLAG d'une instruction sont à 1, le ''scoreboard'' envoie un signal GOREAD à la mémoire et aux unités de calcul. Les numéros/noms de registre, présents dans les registres Fj et Fk, sont envoyés au banc de registre. Le signal envoyé à l'ALU lui demande de copier ces opérandes dans les registres opérandes et de démarrer l'instruction. L'opcode de l'instruction est déjà mémorisé dans le registre d'opcode, ou alors il est envoyé à ce moment. Puis les bits READ FLAG de l'entrée associée à l'ALU sont remis à zéro.
===Le calcul du masque READ PENDING===
Plus haut, nous avions vu le masque READ PENDING qui interdit d'écrire un résultat dans un registre si les conditions ne sont pas réunies, en cas de dépendance WAR. Le nom du signal indique comment sont détectées les dépendances WAR : l'écriture est interdite si une lecture de ce registre est en attente. Le CDC 6600 lit les deux opérandes d'une instruction en même temps, ce qui fait qu'une opérande peut être disponible mais pas encore lue. Une opérande dans ce cas a un READ FLAG à 1 pour le registre associé. Chaque bit du signal READ PENDING se détermine donc en faisant un ET logique entre tous les bits READ FLAG associé à un registre, encore faut-il les sélectionner en fonction du registre voulu et de l'unité de calcul associée au registre.
La méthode permet de gérer les dépendances WAR, mais pas les dépendances WAW. Le CDC 6600 bloquait l'instruction dans l'étage d'émission en cas de dépendance WAW. Si l'instruction à émettre avait pour registre de destination un registre de destination d'une instruction dans le pipeline, le processeur émettrait des bulles de pipeline tant que la première écriture n'a pas eu lieu. Son successeur, le CDC 7600 n'avait pas ce défaut et était capable de passer outre toutes les dépendances de données WAR et WAW. Une raison probable à cela est que le CDC 6600 a été terminé en cataastrophe et que ses concepteurs n'ont pas eu le temps d'implémenter une technique complexe de gestion des dépendances WAW. Mais ils ont eu le temps de terminer le design du ''scoreboard'' pour le successeur 7600. Une autre raison est que le pipeline étant court, les dépendances WAW avaient un effet très limité et que bloquer l'émission n'entrainait pas une baisse de performance notable.
Mais on peut en théorie faire autrement. Le principe est que les instructions sont émises en absence de dépendances RAW, mais que l'écriture de leur résultat peut être retardée à l'étage d'écriture dans les registres. En cas de dépendances WAR ou WAW, le résultat à écrire est maintenu sur la sortie d'une unité de calcul, dans un registre tampon placé sur cette même sortie, comme illustré plus bas. Les instructions sont donc bloquées dans l'unité de calcul, qui n'est plus disponible pour d'autres instructions. Le ''scoreboard'' sait qu'il ne peut plus envoyer d'instruction sur cette unité de calcul.
À chaque cycle, l'étage d'enregistrement choisit le résultat à enregistrer dans les registres, en respectant les dépendances de données. C'est le ''scoreboard'' qui autorise ou non cette écriture, en commandant l'étage d'enregistrement en fin de pipeline. En clair : l'enchainement des signaux GOWRITE impose un enregistrement des résultats qui suit l'ordre du programme.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les schedulers larges et leurs optimisations
| prevText=Les schedulers larges et leurs optimisations
| next=La désambiguïsation mémoire
| nextText=La désambiguïsation mémoire
}}
</noinclude>
iki7ymqen17s2jsse3lbgyj6720nmhw
775259
775258
2026-10-14T14:06:15Z
Mewtow
31375
/* Les stations de réservation */
775259
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir deux techniques d'exécution dans le désordre un peu à part. Il s'agit de deux techniques historiquesn utilisées sur des ordinateurs très anciens, qui portent les noms de ''scoreboarding'' et d'algorithme de Tomasulo. Les deux ne sont plus utilisés aujourd'hui, pour diverses raisons, la principale étant qu'elles ne gèrent pas les exceptions précises. En clair, impossible de les utiliser en même temps que la prédiction de branchement ou d'autres optimisations.
Elles ne gèrent pas les exceptions précises, car elles utilisent des formes de renommage de registre assez originale. L'algorithme de Tomasulo renomme les registres dans les stations de réservation, chose qu'on n'a volontairement pas abordé dans le chapitre sur le renommage de registre. Le ''scoreboarding'' utilise quant à lui une technique similaire, mais bien plus difficile à classer, qui est clairement idiosyncratique. Il est possible de les modifier pour leur adjoindre un ROB, mais le résultat ressemble beaucoup aux techniques de renommage dans le ROB.
==L'algorithme de Tomasulo et les stations de réservation==
L''''algorithme de Tomasulo''' est un algorithme assez compliqué à expliquer, car il mélange exécution dans le désordre, contournement et renommage de registre implicite en un seul algorithme. L'algorithme de Tosmasulo a été implémenté une première fois sur les ordinateurs IBM 360/91, et quelques processeurs ont utilisé une variante de celui-ci. Beaucoup de processeurs modernes se sont sont inspiré de cet algorithme pour leur design. Il est antérieur au ''scoreboarding'' du CDC 6600, qui avait des fonctionnalités similaires, mais nous allons le voir avant, afin de mieux faire ressortir les ressemblances entre les deux algorithmes.
Avec l'algorithme de Tomasulo, le renommage de registre se fait dans les stations de réservation. Il s'agit là d'une forme de renommage de registre assez originale, qui n'est plus utilisée de nos jours. Et la raison est qu'elle n'est pas compatible avec les exceptions précises. L'algorithme original de Tomasulo n'avait pas de ROB et ne gérait pas du tout les exceptions précises. Il est possible d'ajouter un ROB à l'algorithme, mais on retombe alors sur une forme de renommage dans le ROB.
===Les stations de réservation===
L'algorithme de Tosmasulo a été utilisé tel quel sur la FPU de l'IBM 360 modèle 90, et uniquement celle-ci. Les ALU entière étaient séparées de la FPU et n'étaient pas concerné par tout ce qui va suivre Le processeur n'utilisait l'exécution dans le désordre que pour sa FPU, pas le "pipeline" entier/mémoire. La FPU en question avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes.
Les trois unités avaient chacune leur propre '''station de réservation'''. Pour rappel, ce terme décrit des ''schedulers'' qui ont deux particularités :
* Ils mémorisent non seulement les µops en attente, mais aussi leurs opérandes.
* Le ''scheduler'' est décentralisé au point d'utiliser un ''scheduler'' par unité de calcul.
Dans son acceptation la plus stricte, le terme station de réservation est réservé au cas où il y a une station de réservation par unité de calcul. Et c'est le cas avec l'algorithme de Tomasulo initial, qui a été utilisé pour la première fois sur un processeur avec trois stations de réservation : une pour le circuit multiplieur/diviseur flottant, une pour l'additionneur flottant, et une pour l'unité LOAD/STORE.
L'unité LOAD/STORE était composé d'une file pour les lectures et d'une file d'écriture. Elle avait une station de réservation pour chaque. La file pour les lectures mettait en attente les données lues depuis la mémoire, le temps qu'elles soient utilisés dans un calcul ou écrites dans les registres. Les lectures pouvant se faire dans le désordre, la file de lecture ne servait pas à les remettre en ordre, juste à les mettre en attente. Elle contenait 6 entrées, donc 6 lectures terminées mises en attente. La file d'écriture était une file d'écriture classique, à trois entrées.
Les adresses étaient calculées dans le désordre, et le processeur pouvait faire les accès mémoire dans le désordre. Cependant, le processeur intégrait des techniques de désambiguïsation mémoire, pour éviter tout problème. Mais ces mécanismes n'étaient pas gérés dans la FPU, ni par l'algorithme de Tomasulo. Ils étaient gérés dans une autre structure, proche du bus mémoire, appelée la ''store-fetch sequencing unit''.
[[File:Tomasulo Architecture.png|centre|vignette|upright=2.5|Algorithme de Tomasulo originel, de la FPU de l'IBM 360.]]
Pour rappel, les stations de réservation impliquent une séparation entre ''dispatch'' et ''schedule''. En sortie de l'unité de décodage, les µops sont renommées puis envoyées à la station de réservation adéquate lors d'une étape de ''dispatch''. Elles attendent alors leur tour dans les stations de réservation, puis sont envoyées dans l'unité de calcul lors del'étape de ''schedule''.
Les stations de réservation mémorisent les µops en attente d'exécution, ainsi que leurs opérandes. Lors du ''dispatch'', l'unité de ''dispatch'' vérifie quelles opérandes sont dans les registres. Les opérandes déjà disponibles sont alors lues depuis les registres et mémorisées dans la station de réservation. Les autres opérandes, pas encore disponibles au moment de l'''issue'', seront fournies plus tard par le système de contournement. Les opérandes ont donc deux sources : registres, contournement. Pour rappel, il s'agit d'une technique dite de '''lecture avant émission''', qui lit les registres d'une µop avant de l'émettre.
Les opérandes disponibles à l'émission, lues depuis les registres, sont directement enregistrées dans la station de réservation. Leur bit de présence est mis à 1, il n'y a pas de renommage de registres, car il n'y en a pas besoin pour ces opérandes. Les opérandes non-disponibles seront envoyées via contournement plus tard et le renommage de registre doit être utilisé. L'opérande est associée à l'identifiant de résultat qui permet de savoir quand elle sera calculée.
===Le bus de contournement===
Les résultats d'une µop sont non seulement envoyés les registres, mais ils sont aussi envoyés vers les stations de réservation, via un réseau de contournement un peu particulier. La sortie de chaque unité de calcul est connectée à toutes les stations de réservation, via un bus appelé le ''bus commun d’ordonnancement de la mémoire'' (''common memory-ordering bus''), que nous nommerons dorénavant ''bus de contournement''.
[[File:Algorithme de Tomasulo.png|centre|vignette|upright=2|Algorithme de Tomasulo]]
Le '''bus de contournement''' n'est pas un réseau de contournement classique. Il est plus simple et utiliser moins de fils. Le fait que ce soit un bus, et non un vrai réseau complet, fait qu'une seule unité de calcul peut envoyer des données sur ce bus. Si plusieurs unités de calcul veulent transmettre leurs résultats sur ce bus, l'une d'entre elle est choisie et les autres doivent mettre en attente leurs résultats. La mise en attente se fait grâce à des registres situés en sortie des ALU, appelés des ''producers''. L'implémentation demande de bloquer des opérations dans l'ALU si le contournement doit être mis en attente, ce qui bloque tout le pipeline en cas de problème.
[[File:Producteurs en sortie d'une ALU.jpg|centre|vignette|upright=2|Producteurs en sortie d'une ALU.]]
Le bus de contournement est assez spécifique à l'algorithme de Tomasulo, dans le sens où c'est un des rares articles académique à utiliser un tel bus de contournement.
===Le renommage de registres dans les stations de réservation===
L'unité d'émission attribue à chaque résultat de calcul un '''identifiant de résultat'''. L'identifiant est une sorte de numéro de registre virtuel, qui est transmis sur le bus de contournement. L'identifiant est propagé comme tout signal de commande, il suit la µop sur son trajet dans le pipeline. Quand l'unité de calcul a fini son calcul, l'identifiant est envoyé avec le résultat sur le bus de contournement. Les stations de réservation de destination, qui ont cet identifiant dans leur entrée, enregistrent le résultat dans le champ adéquat.
Pour attribuer un identifiant de résultat, l'unité d'émission contient un circuit équivalent à une ''register alis table''.
Les entrées des stations de réservation sont structurées en plusieurs champs. Le premier mémorise l'opcode de l'instruction/µop. Il y a aussi un champ pour l'identifiant de résultat. Il y a aussi deux champs, un par opérande. Il peut contenir deux choses : l'opérande elle-même, l'identifiant de résultat associé. Pour faire la différence, les deux champs ont chacun un bit de présence, qui indique si l'opérande est présente dans le champ ou non (auquel cas, c'est un identifiant).
[[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]]
Vous vous demandez certainement où sont les registres virtuels avec cette organisation. Et bien il ne s'agit ni plus ni moins que des stations de réservation. Et plus précisément les champs pour les opérandes. L'identifiant de résultat est l'équivalent d'un nom de registre virtuel. D'ailleurs, une fois la µop émise, il n'y a pas de noms de registre physique dans les stations de réservation, seulement des identifiants de résultat. En somme, le renommage de registre est pris en charge par les stations de réservation, et par le système de contournement.
==Le ''scoreboarding'' du CDC 6600==
L'implémentation du ''scoreboard'' sur le CDC 6600/7600 est appelée dans la suite du cours : le '''''scoreboarding'''''. La description que je vais donner du ''scoreboard'' est très différente de celle disponible dans les livres d'architecture des ordinateurs. La plupart des manuels, surtout les textbooks américains, donnent une description très haut niveau, très algorithmique du ''scoreboarding'', qui ne sera pas abordée.
À la place, je vais donner une description alternative, différente autant sur l'approche que le fond.
Le CDC 6600 n'existe plus, tous les exemplaires ont depuis longtemps été démontés, aussi il ne reste que des sources historiques pour savoir comment fonctionnait son ''scoreboard''. Heureusement, il existe un livre qui décrit dans les moindres détails cette architecture, écrit par un des concepteurs de l'ordinateur : [https://archive.computerhistory.org/resources/text/CDC/cdc.6600.thornton.design_of_a_computer_the_control_data_6600.1970.102630394.pdf Design of a computer : the CDC 6600]. Ce qui va suivre est majoritairement ité de cette source.
===Le chemin de données du CDC 6600 et sa relation avec le ''scoreboard''===
Le pipeline d'un processeur avec ''scoreboarding'' ressemble à celui du schéma suivant. Pour résumer, le chemin de donnée forme un pipeline à trois étages : lecture des registres, exécution dans l'ALU, écriture dans les registres. Les opérations à effectuer son envoyées aux ALUs, et elles attendent leurs opérandes dedans.
[[File:Scoreboard avec exceptions imprécises.png|centre|vignette|upright=2|Scoreboard avec exceptions imprécises]]
La séparation en trois étages fait qu'il y a des registres entre chaque étages. Les opérandes sont chargées dans des registres en amont de l'ALU, et le résultat est enregistré temporairement dans un registre en sortie de l'ALU. Nous les appellerons : '''registres opérandes''' et '''registre résultat'''. Entre les deux, l'ALU effectue une opération quand les opérandes sont disponibles dans les registres opérandes, et maintien le résultat dans le registre résultat si besoin. Il y a aussi un autre registre qui mémorise l'opcode de l'opération, lors de l'attente des opérandes, mais aussi lors de l'exécution. Nous l'omettrons dans ce qui suit, car il s'agit d'un registre de pipeline classique.
Les registres opérande et résultat n'ont pas besoin d'être très complexes vu qu'ils servent juste à maintenir une donnée. Les registres ne sont pas connectés à l'horloge, les bascules ne sont pas synchrones, ils sont fabriqués à partir de simples ''latches'', de simples bascules D non-synchrones. Et il en est de même pour le registre d'opcode. Le ''scoreboard'' commande le signal Enable qui autorise les écritures dedans, rien de plus. Le pipeline est donc le suivant :
[[File:Pipeline d'un processeur utilisant le scoreboarding.png|centre|vignette|upright=2|Pipeline d'un processeur utilisant le scoreboarding]]
Les registres opérandes sont chacun associé à un bit READ FLAG qui précise si l'opérande du calcul est disponible. Les bits READ FLAG sont mis à jour par un mécanisme assez complexe, qui sera vu dans la suite. Le ''scoreboard'' gère les transferts entre les registres opérande/résultat et le reste du processeur. Il génère deux signaux de 1 bit nommés GO READ et GO STORE/GO WRITE. Le signal GO READ déclenche la copie d'un registre architectural dans un registre opérande. Le signal GO WRITE déclenche la copie du registre résultat dans le registre architectural adéquat.
Pour effectuer une lecture ou une écriture, les noms des registres adéquats sont mémorisés dans deux registres notés Fi, Fj, et Fk. Fi mémorise le registre de destination de l'opération, Fj et Fk mémorisent les noms/numéros des registres opérandes. Les deux registres Qj et Qk sont utilisés pour la détection des opérandes disponibles, nous en reparlerons plus tard. Les registres Fi, FJ et Fk sont dans le ''scoreboard'', et ils sont envoyés sur les entrées d'adresse du banc de registre suivant les besoins, suivant qu'on fasse une lecture ou une écriture. La lecture et écriture des registres donc totalement le fait du ''scoreboard'', qui commande le banc de registres, comme le ferait un séquenceur normal.
[[File:CDC 6600 datapath control.png|centre|vignette|upright=2|CDC 6600 datapath control]]
Notez que les registres opérandes, couplés au registre d'opcode, peuvent être vus comme une station de réservation simplifiée, sans gestion du contournement. La différence principale avec l'algorithme de Tomasulo est que les champs pour les identifiants de résultat ont disparus, et que les bits de disponibilité des opérandes READ FLAG sont en réalité intégré au ''scoreboard'', comme on le verra plus tard, avec une gestion de la disponibilité des opérandes totalement différente. De plus, il n'y a qu'une seule station de réservation par ALU, donc un risque plus grand de dépendances structurelles. On ne peut mettre en attente qu'une seule µop par ALU, avec le ''scoreboard''.
===Les désignateurs utilisés par le ''scoreboard''===
Les registres opérandes et résultat ne sont pas des registres architecturaux, mais ce ne sont pas des registres de pipeline non plus. Il est possible de faire l'analogie avec une station de réservation, mais l'analogie est limitée pour une raison simple : le ''scoreboard'' attend que les deux opérandes sont disponibles pour déclencher la lecture avec le signal GO READ. En théorie, il est tout à fait possible de lire un registre opérande à la fois, dès que l'opérande est disponible, mais le ''scoreboard'' ne faisait pas comme ça. De plus, les opérandes ne sont pas vraiment transmises via un réseau de contournement depuis la sortie de l'ALU.
Cependant, une rumeur veut que le processeur implémente un système de contournement, qui passe...par l'intermédiaire des registres. Paradoxal, mais qui s'explique pourtant très bien quand on connait le fonctionnement des registres utilisés. Le processeur utilisait une ancienne technologie de transistors, appelée ECL. Beaucoup de bancs de registres fabriqués avec cette technologie avaient une propriété intéressante : toute donnée écrite sur le port d'écriture était immédiatement disponible sur le port de lecture. Plus précisément, l'écriture se faisait sur front montant et la lecture sur front descendant. En faisant cela, une opérande pouvait être écrite dans les registres architecturaux, mais pourtant copiée dans les registres opérandes au même cycle. Le tout fonctionne presque comme un système de contournement, avec cependant quelques limitations ! Avec un vrai système de contournement, l'implémentation est beaucoup plus puissante.
Quant à savoir si les registres opérandes sont nommés, c'est un peu compliqué et la réponse serait qu'ils sont nommés de manière implicite. Il est d'ailleurs débattable de savoir si le CDC 6600/7600 implémente le renommage de registres... Le point de discorde est lié au fait que le ''scoreboard'' attribue un numéro à chaque unité de calcul, appelé le '''désignateur Q'''. Et ces identifiants sont utilisés pour la lecture des opérandes et leur copie dans les registres, ils se comportent comme un nom de registre virtuel.
Pour simplifier les explications, nous allons partir du principe que les désignateurs sont encodés en représentation unaire. Pour rappel, la représentation unaire représente un nombre sur plusieurs bits, dont un seul est à 1. Un zéro est codé en mettant tous les bits du nombre à zéro, la valeur 1 est encodée avec la valeur 000...0001, le deux avec 000...0010, etc.
{|class="wikitable"
|+ Désignateur d'ALU
|-
! ALU ADD !! ALU MUL !! ALU DIV !! ALU SUB !! ALU CMP !! ALU MOV
|-
| 0 || 0 || 1 || 0 || 0 || 0
|}
La réalité est que les désignateurs sont présents à plusieurs endroits et sont soit codés en unaires, soit codés en binaire, avec des traductions fréquentes entre les deux. Ils sont par exemple traduits du binaire vers l'unaire lors de la génération des signaux READ FLAG. Les traductions inverses sont aussi fréquentes. Les traductions se basent sur des décodeurs/encodeurs un peu modifiés.
L'avantage de la représentation unaire est que les comparaisons sont légèrement plus simples, les comparateurs économisent quelques portes logiques. De plus, elle s'adapte assez bien avec le fait que chaque unité de calcul précise si elle est disponible ou non, ce qui deviendra plus clair dans la suite.
===Les réservations et la table de réservation===
Le ''scoreboard'' du CDC 6600 contient une table de '''réservation''', qui permet de réserver une unité de calcul pour une µop à émettre. La table de réservation est composée d'entrées, une par unité de calcul, qui peut mémoriser une µop. Une entrée mémorise pour chaque unité de calcul :
* l'opcode de l'instruction à exécuter dessus ;
* les noms/numéros des registres opérandes et destination ;
* les désignateurs Q des unités de calcul qui fourniront les opérandes en résultat ;
* deux bits READ FLAG qui disent si l'opérande associé est disponible.
: Dans le livre utilisé comme source, il n'est pas clair si les noms des registres opérandes/résultat sont placés dans l'unité de calcul ou dans le ''scoreboard''. Les explications semblent indiquer que c'est juste à côté de l'ALU, mais les schémas se contredisent là-dessus. Dans la suite, je pars du principe qu'ils sont dans le ''scoreboard'', ce qui aurait bien plus de sens.
Le ''scoreboard'' consulte la table de réservation pour émettre une µop. Avant d’émettre une µop, elle regarde si l'entrée est libre ou déjà occupée par une µop. Pour cela, chaque entrée est associée à un bit BUSY qui indique si l'entrée est vide ou occupée. Vu qu'il y a une entrée par unité de calcul, attribuer une entrée à une µop est asszz simple : chaque µop va dans une entrée bien précise, il suffit de vérifier celle-ci. Le bit BUSY est mis à 1 quand une µop est émise, ajoutée dans l'entrée, mis à zéro quand elle est terminée. La libération d'une entrée se fait quand le signal GO WRITE est émis, le signal RELEASE ne suffit pas.
La réservation permet de réserver une ALU pour une opération. La µop est alors envoyée à l'ALU, où elle attend que ses opérandes soient disponibles. Une µop attend son tour et n'est émise qu'une fois que les deux bits READ FLAGS sont à 1. La conséquence est qu'une µop reste dans l'unité de calcul tant qu'elle a des dépendances RAW. Et seules les dépendances RAW, où une instruction pour opérande le résultat d'une instruction précédente, impliquent un blocage du pipeline et des bulles de pipeline. Les dépendances WAW ou WAR sont traitées autrement.
La table de réservation ne stocke pas les opérandes, ce qui fait qu'on peut la voir comme un ''scheduler'' à une entrée, ou encore comme une station de réservation castrée. Un détail très important est que ce système de réservation met en attente une µop par ALU. Vu que toutes les ALUs du CDC 6600 sont très différentes, l'attribution d'une µop à une ALU est trivial. Par contre, si une µop veut utiliser une ALU réservée, elle ne peut pas et il faut bloquer le pipeline. Le système de réservation est donc une cause de dépendances structurelles qui bloquent l'émission des µops dans de nombreux cas. Ce ne serait pas le cas si on pouvait placer plusieurs réservations par unité de calcul, mais passons.
Pour une dépendance WAR, la µop est émise, elle s'exécute, mais son résultat est maintenu dans le registre résultat le temps que ces dépendances soient résolues. Pour le dire autrement, Les dépendances WAR font que les µops sont retenues dans l'étage d'enregistrement des données, un peu comme sur un processeur à pipeline multicycle (de longueur fixe). Sauf que les pipeline multicycle, il y aurait plusieurs registres d'échelonnements qui se suivent, alors qu'on n'en a qu'un ici, ce qui fait que l'unité de calcul est bloquée si l'écriture est retardée. En théorie, les dépendances WAW devraient être traitées avec un mécanisme similaire, mais le processeur détectait ces dépendances à l'émission et bloquait l'émission si un registre lu était accédé en lecture.
[[File:Enregistrement des données dans les registres avec un pipeline avec scoreboarding.png|centre|vignette|upright=2|Enregistrement des données dans les registres avec un pipeline avec scoreboarding]]
===La Q-table : l'unité de renommage de registres===
Quand une µop est émise, elle réserve une unité de calcul. Mais il faut pour cela remplir les champs pour les désignateurs d'ALU, même si on voit mal à quoi ils servent pour le moment. Il se trouve que les registres opérandes contiennent une valeur, qui est le résultat d'une µop soit déjà passée par une ALU, soit en cours d'exécution dans une ALU. Le désignateur pour un opérande est donc le désignateur de cette ALU. Reste à faire la correspondance entre un résultat/registre et l'ALU qui va le calculer ou l'a déjà calculé. Pour cela, le ''scoreboard'' mémorise la correspondance entre un registre/résultat et une unité de calcul dans une mémoire cache appelée la '''''Q-table'''''. Pour comprendre à quoi elle sert, prenons deux instructions consécutives, dont la seconde utilise le résultat de la première.
La première instruction est lancée dans une unité de calcul enregistre son résultat dans un registre de destination. Le ''scoreboard'' mémorise alors dans la ''Q-table'' que le registre de destination est associé à telle unité de calcul, elle mémorise une correspondance entre le désignateur de l'ALU et le registre de destination. Par la suite, la seconde instruction est émise. Le ''scoreboard'' interroge la ''Q-table'' pour les deux opérandes de l'instruction, pour récupérer les deux désignateurs de l'ALU associés. Ils sont envoyés au banc de registres pour gérer la lecture des opérandes.
Pour résumer, le ''scoreboard'' effectue les actions suivantes lors de l'émission.
* Premièrement, il détermine si l'unité de calcul est disponible ou non, en utilisant le bit de disponibilité BUSY.
* Deuxièmement, il mémorise les numéros de registres opérande et résultat pour commander la lecture des opérandes.
* Troisièmement, il traduit les numéros de registres opérande en identifiants d'ALU.
* Quatrièmement, il mémorise dans une ''Q-table'' la relation entre le registre de destination et l'unité de calcul choisie à l'étape 1.
La troisième étape permet de faire le lien entre un résultat calculé mais pas encore enregistré dans les registres, et un registre opérande non-nommé. Le lien guide le chargement des opérandes et donc le système de contournement passant par des registres (ce qui est du renommage de registre implicite).
===La gestion des résultats : les signaux RELEASE et GOWRITE===
Une fois la µop exécutée, son résultat est enregistré dans le registre résultat. Une fois le résultat dans le registre résultat, l'unité de calcul envoie un signal REQUEST au ''scoreboard'' pour savoir si la donnée peut être enregistrée dans les registres architecturaux. L'ensemble des signaux REQUEST est regroupé dans un vecteur de bit, une sorte de nombre, dont chaque bit indique quelle unité veut écrire un résultat. L'ensemble des signaux RELEASE forment un nombre encodé en unaire qui indique quelle ALU a fournit le résultat. Là, le ''scoreboard'' effectue plusieurs opérations successives sur ce nombre, en appliquant plusieurs masques.
Le premier masque appliqué vérifie pour chaque ALU si elle a le droit d'écrire son résultat en mémoire, sur la base des dépendances WAW et WAR. En cas de dépendances WAR ou WAW, l'écriture du résultat est retardé, ce qui fait que l'unité de calcul n'est pas considérée, son signal RELEASE est ignoré, il est masqué. Le masque qui permet cela est appelé le '''masque READ PENDING'''.
Le second masque est un circuit de priorité qui sélectionne un bit seulement. Il est en effet possible que plusieurs unités de calcul fournissent un résultat en même temps. Ou encore que l'une ait son écriture retardée et ait un signal RELEASE à 1 depuis plusieurs cycles. Toujours est-il qu'on ne peut écrire qu'un résultat à la fois, ce qui fait qu'il faut sélectionner une unité de calcul. Pour cela, un circuit de priorité sélectionne un seul signal RELEASE à 1 et met les autres à zéro. C'est le second masque.
{|class="wikitable"
|+ Traitement des signaux REQUEST
|-
! colspan="10 | Signaux REQUEST en sortie des ALUs
|-
| 0 || 1 || 0 || 1 || 1 || 1 || 0 || 0 || 0 || 0
|-
! colspan="10 | Application du masque READ PENDING pour les dépendances WAW/WAR
|-
| 0 || 0 || 0 || 1 || 1 || 0 || 0 || 0 || 0 || 0
|-
! colspan="10 | Sélection par un circuit de priorité
|-
| 0 || 0 || 0 || 1 || 0 || 0 || 0 || 0 || 0 || 0
|}
Après l'application du second masque, il ne reste qu'un seul bit à un, qui indique quelle ALU a été sélectionnée pour l'écriture. Il se trouve que l'ensemble des bits forme un désignateur d'ALU, encodé en représentation unaire, en représentation ''one-hot''. Pour rappel, cela veut dire que seul un bit dans le nombre est à 1 et ce bit indique la valeur encodée. Si le bit numéro X est à 1, alors la valeur encodée vaut X. L'usage d'une représentation unaire/''one-hot'' fait que l'on a juste à concaténer les signaux REQUEST et appliquer des masques pour obtenir le désignateur adéquat, pas besoin d'encodeur compliqués, ni de circuits de sélection complexes. Le désignateur résultat est appelé '''désignateur d'ALU choisie'''.
Une fois ce désignateur calculé, le ''désignateur d'ALU choisie'' est envoyé à la table de réservation, afin de récupérer le nom/numéro du registre résultat associé. Le ''scoreboard'' commande le port d'écriture du banc de registre, avec un signal GOWRITE et le nom/numéro du registre de destination récupéré. Le signal RELEASE est alors remis à zéro. Reste à générer le signal GO WRITE et faire les actions adéquates.
Mais il reste deux opérations de maintenance pour terminer l'écriture. La première est de remettre en ordre la Q-table. L'entrée qui liait l'unité de calcul au registre de destination doit être remise à zéro. Le résultat ayant été écrit, il n'est plus associé à l'unité de calcul, le lien entre les deux dans la Q-table est donc effacé. La seconde est de gérer la disponibilité des opérandes. Pour cela, le résultat après application des deux masques est aussi utilisé pour détecter la disponibilité des opérandes, pour mettre à 1 le bit READ FLAG adéquat. Là encore, c'est la table de réservation qui s'en charge.
===La détection des opérandes disponibles : la génération des READ FLAG et du signal GOREAD===
Le désignateur d'ALU choise est envoyé à la table de réservation pour récupérer le nom de registre destination, mais aussi aux champs opérandes. Dans chaque entrée, il comparé aux deux désignateurs Qj et Qk, associés chacun à une opérande. Pour rappel, les désignateurs Qj et Qk sont des désignateurs d'unité de calcul, eux aussi encodés en unaire. S'il y a ''match'', alors l'opérande est disponible. Le comparateur génère un signal qui met à 1 le bit de disponibilité READ FLAG (i pour le numéro de l'unité de calcul), le bit est dans une bascule pour ne pas être effacé au cycle suivant.
Le ''scoreboard'' contient donc une matrice de comparaison, qui compare le résultat de l'étape précédente avec tous les désignateurs associés à une opérande. L'avantage d'encoder les désignateurs en unaire fait que la comparaison est plus simple : elle se constitue uniquement de portes ET, pas besoin de portes XOR comme on en aurait eu besoin en utilisant des désignateurs en binaire. De plus, le calcul du désignateur à partir des signaux REQUEST est trivial, pas besoin de passer par un encodeur.
[[File:Détection des dépendances CDC 6600.png|centre|vignette|upright=2|Détection des dépendances RAW CDC 6600]]
Quand les deux bits READ FLAG d'une instruction sont à 1, le ''scoreboard'' envoie un signal GOREAD à la mémoire et aux unités de calcul. Les numéros/noms de registre, présents dans les registres Fj et Fk, sont envoyés au banc de registre. Le signal envoyé à l'ALU lui demande de copier ces opérandes dans les registres opérandes et de démarrer l'instruction. L'opcode de l'instruction est déjà mémorisé dans le registre d'opcode, ou alors il est envoyé à ce moment. Puis les bits READ FLAG de l'entrée associée à l'ALU sont remis à zéro.
===Le calcul du masque READ PENDING===
Plus haut, nous avions vu le masque READ PENDING qui interdit d'écrire un résultat dans un registre si les conditions ne sont pas réunies, en cas de dépendance WAR. Le nom du signal indique comment sont détectées les dépendances WAR : l'écriture est interdite si une lecture de ce registre est en attente. Le CDC 6600 lit les deux opérandes d'une instruction en même temps, ce qui fait qu'une opérande peut être disponible mais pas encore lue. Une opérande dans ce cas a un READ FLAG à 1 pour le registre associé. Chaque bit du signal READ PENDING se détermine donc en faisant un ET logique entre tous les bits READ FLAG associé à un registre, encore faut-il les sélectionner en fonction du registre voulu et de l'unité de calcul associée au registre.
La méthode permet de gérer les dépendances WAR, mais pas les dépendances WAW. Le CDC 6600 bloquait l'instruction dans l'étage d'émission en cas de dépendance WAW. Si l'instruction à émettre avait pour registre de destination un registre de destination d'une instruction dans le pipeline, le processeur émettrait des bulles de pipeline tant que la première écriture n'a pas eu lieu. Son successeur, le CDC 7600 n'avait pas ce défaut et était capable de passer outre toutes les dépendances de données WAR et WAW. Une raison probable à cela est que le CDC 6600 a été terminé en cataastrophe et que ses concepteurs n'ont pas eu le temps d'implémenter une technique complexe de gestion des dépendances WAW. Mais ils ont eu le temps de terminer le design du ''scoreboard'' pour le successeur 7600. Une autre raison est que le pipeline étant court, les dépendances WAW avaient un effet très limité et que bloquer l'émission n'entrainait pas une baisse de performance notable.
Mais on peut en théorie faire autrement. Le principe est que les instructions sont émises en absence de dépendances RAW, mais que l'écriture de leur résultat peut être retardée à l'étage d'écriture dans les registres. En cas de dépendances WAR ou WAW, le résultat à écrire est maintenu sur la sortie d'une unité de calcul, dans un registre tampon placé sur cette même sortie, comme illustré plus bas. Les instructions sont donc bloquées dans l'unité de calcul, qui n'est plus disponible pour d'autres instructions. Le ''scoreboard'' sait qu'il ne peut plus envoyer d'instruction sur cette unité de calcul.
À chaque cycle, l'étage d'enregistrement choisit le résultat à enregistrer dans les registres, en respectant les dépendances de données. C'est le ''scoreboard'' qui autorise ou non cette écriture, en commandant l'étage d'enregistrement en fin de pipeline. En clair : l'enchainement des signaux GOWRITE impose un enregistrement des résultats qui suit l'ordre du programme.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les schedulers larges et leurs optimisations
| prevText=Les schedulers larges et leurs optimisations
| next=La désambiguïsation mémoire
| nextText=La désambiguïsation mémoire
}}
</noinclude>
1wmaw0dmv9culxdls46cds3iz6qiivq
Fonctionnement d'un ordinateur/Les mémoires cache
0
65957
775313
774332
2026-10-15T01:10:21Z
Mewtow
31375
/* La situation avec une hiérarchie de caches */
775313
wikitext
text/x-wiki
La '''mémoire cache''' est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Sans elle, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire RAM est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instructions (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente.
==Les caches internes ou externes (au processeur)==
Les tout premiers processeurs utilisaient un cache externe, séparé du processeur. Mais de nos jours, le cache est intégré dans le processeur, directement. La loi de Moore a permis d'utiliser des transistors pour le cache, en plus des cœurs du processeur. Et cela nous amène à distinguer deux types de caches : les caches externes et internes. Les '''caches externes''' sont des caches qui sont à l'extérieur du processeur. Ils sont soudés sur la carte mère, ou ont leur propre connecteur, les deux sont possibles. À l'inverse, les '''caches internes''' sont intégrés dans le processeur, à l'intérieur de celui-ci.
Intégrer le cache dans le processeur a divers avantages. Le premier est que le cache est plus rapide quand il est dans le processeur que dehors. Les transferts entre cache et processeurs restent dans le processeur, il n'y a pas besoin de passer dans des interconnexions avec l'extérieur, qui sont assez lentes. Le cache a aussi tendance à aller à la même fréquence que le processeur, même s’il faut nuancer, comme on le verra plus bas.
Les caches internes sont tous fabriqués avec de la mémoire SRAM, la seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Pour les caches externes, on pourrait d'attendre à ce qu'ils soient faits en mémoire DRAM, mais c'est assez rare. En général, ils sont faits avec de la mémoire SRAM, comme les caches internes. Il y a cependant quelques exceptions, des caches externes qui utilisaient de la mémoire eDRAM.
===Les caches internes et la hiérarchie de caches===
L'intégration du cache dans le processeur ne suffit plus de nos jours. Les processeurs sont devenus tellement rapides qu'ils ne peuvent plus se contenter d'un seul cache ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Et qui dit même problème, dit même solution !
Les processeurs modernes utilisent une hiérarchie de mémoires caches, allant d'un cache très petit mais très rapide, à un cache plus lent mais de grande capacité. Les processeurs assez anciens n'utilisaient pas de cache L3, ils avaient juste un cache L1 petit et rapide, et un cache L2 gros et lent.
[[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]]
Depuis les années 2010, les processeurs utilisent une hiérarchie à trois niveaux de cache. Les caches en question sont appelés le '''cache L1''' pour le plus petit et le plus rapide, le '''cache L2''' pour le cache intermédiaire, et le '''cache L3''' pour le cache gros et lent. Il arrive même que certains processeurs aient un '''cache L4''', très gros mais aussi très lent.
[[File:Cache Hierarchy.png|centre|vignette|upright=1.5|Hiérarchie de caches]]
La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un '''cache L1 de données''' pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache L2 mémorise indifféremment données et instructions. La séparation entre cache L1 de données et d’instruction est apparue en même temps que la scission entre cache L1 et L2.
[[File:Cache d'instructions.png|centre|vignette|upright=2|Cache d'instructions.]]
Le nombre de niveaux de cache varient d'un processeur à l'autre. Les tout premiers processeurs faisaient avec un cache unique, sans hiérarchie de cache. Mais par la suite, la scission entre L1 et L2 est apparue. Puis un cache L3 a été ajouté, avec parfois un cache L4. Pour rester général, la hiérarchie de caches est composée de plusieurs '''niveaux de cache''', qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides.
Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Concrètement, c'est le cache L2 sur les anciens processeurs, le cache L3 ou L4 sur les processeurs modernes. Le cache de dernier niveau a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicœurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie du contrôleur mémoire ou de l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. Il est fréquent que le cache de dernier niveau ailler à une fréquence plus basse que celle du processeur. Le Pentium 2 avait par exemple un cache de dernier niveau qui allait à la moitié de la fréquence du CPU.
Petit point terminologie avant de poursuivre : j'utiliserais parfois l'expression "niveaux de cache inférieurs". L'expression se fait quand on parle d'un niveau de cache bien précis, souvent le niveau L1. Quand on parle des "niveaux de cache inférieurs au L1", cela veut dire le cache L2 et L3, voire ceux en dessous s'ils existent. Idem pour les "niveaux de cache inférieurs au L2", qui désigne le cache L3. La mémoire RAM est souvent implicitement comptée dans cette expression, même si elle n'est pas un cache à proprement parler.
===Les caches externes : les ''Cache On A STick''===
[[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]]
Les processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache On A STick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais ils étaient fabriqués en mémoire SRAM.
L'avantage d'un cache externe est que cela permettait de mettre plus de cache, à une époque où le budget en transistor était limité. Un autre avantage est que le consommateur pouvait choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était aussi possible d'upgrader le cache si besoin.
Pour les concepteurs de processeur, c'était aussi un moyen d'économiser des transistors, pour privilégier un maximum le processeur lui-même. Par exemple, c'est la raison pour laquelle le processeur PA 7100 n’intégrait aucun cache et devait se débrouiller avec un cache externe. Derrière ce choix se cachait une volonté d'utiliser un maximum de transistors pour le processeur proprement dit. Les modèles ultérieurs, comme le PA 7300, intégraient des caches L1, mais le L2 était déporté hors du CPU. Le choix d'HP était d'avoir un unique niveau de cache, optimisé de manière à pouvoir être à la fois rapide et de grande capacité.
Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Les ''Cache on a stick'' ont été introduits sur les processeurs Motorola, et utilisés sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la plupart des modèles. Les processeurs PA RISC de HP utilisaient eux aussi des caches externes. Les premiers processeurs x86 d'Intel avaient un cache L2 externe, de type ''Cache on a stick'', et ils vont nous intéresser dans ce qui suit.
Le Pentium 2 avait une organisation assez particulière. Le processeur était placé non pas sur un ''socket'', comme la quasi-totalité des processeurs, mais sur une carte fille qu'on clipsait sur la carte mère. La carte fille ressemblait à une barrette de RAM de grande taille, sur laquelle était soudé/connecté le processeur. Et le cache L2 était placé sur la carte fille. Ce n'était donc pas vraiment du ''cache on a stock'', mais le processeur était séparé du cache, les deux étaient dans des circuits séparés.
[[File:Intel Pentium II 400 SL357 SECC2.jpg|centre|vignette|upright=2|Pentium 2 : carte fille. Le processeur est au centre, le cache correspond aux deux circuits à droite.]]
Une différence avec les caches internes est qu'il n'y a pas de hiérarchie de cache avec les caches externes. Un cache externe est, par définition, un cache de dernier niveau. Le processeur peut avoir un cache L1, et utiliser un cache externe qui sera alors un cache L2. Ou encore, un processeur peut utiliser une hiérarchie de cache L1-L2-L3, mais avoir un cache externe, qui sera de niveau L4.
Un cache externe est connecté au bus mémoire s'il existe, au bus système sinon. Il intercepte les lectures à destination de la mémoire RAM, et y répond s'il a une copie de la donnée demandée. Les lectures étaient servies soit par le cache s'il avait une copie de la donnée, par la RAM sinon. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. Pour les écritures, elles se font généralement à la fois dans la RAM et le cache externe.
[[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]]
===Les caches mi-internes, mi-externes : les caches eDRAM===
Les '''caches eDRAM''' sont des caches assez rares, à mi-chemin entre cache internet et externe. Si je dis à mi-chemin, c'est car tout dépend de ce que veut dire "en-dehors d'un processeur". La réponse peut paraitre évidente, mais elle ne l'est pas si le processeur utilise une organisation en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégrés séparés, mais interconnectés et soudés sur un même PCB carré. En retirant la céramique qui recouvre le processeur, on a bien plusieurs circuits.
De rares processeurs avaient plusieurs circuits intégrés sur le même PCB : un pour le processeur proprement dit, un autre pour les interconnexions avec l'extérieur, et un pour un cache externe. Le cache était donc dans le "processeur" au sens du machin carré qu'on insère dans un ''socket'' de carte mère, mais dans un circuit intégré séparé. D'où le fait que j'ai parlé de cache à mi-chemin entre interne et externe. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, que nous allons voir dans la suite du chapitre.
De tels caches étaient fabriqués avec de la '''mémoire eDRAM''' (''embedded Dynamic Random Access Memory''), une forme de mémoire DRAM un peu particulière, fabriquée avec les mêmes technologies que celles utilisées pour les circuits intégrés. Ils avaient une densité intéressante, de bonnes performances, mais devaient être rafraichis comme toute mémoire DRAM. C'était les seuls caches qui n'étaient pas fabriqués avec de la mémoire SRAM, les seuls à avoir besoin de rafraichissement régulier.
Un exemple est celui du cache L4 des processeurs Intel, de microarchitecture ''Broadwell''. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées :
* une puce pour le processeur proprement dit ;
* une puce nommée ''Crystal Well'' contenant 128 mébioctets de mémoire eDRAM ;
* et une puce IO pour la communication avec la RAM et la carte mère.
La puce ''Crystal Well'' était une mémoire eDRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication.
Le cache ''Crystal Well'' était utilisable à la fois par le processeur, mais aussi par le GPU intégré au processeur. Les processeurs de l'époque intégraient déjà une carte graphique rudimentaire, peu puissante, mais suffisante pour jouer à des jeux vidéos anciens, et aussi pour afficher le bureau de Windows/Linux/MacOS, etc. Le GPU lit et écrit des données en mémoire RAM, comme le CPU. Mais en général, il n'a pas accès aux caches L1 et L2 du processeur. Le cache L4 externe pouvait mémoriser indifféremment données pour le CPU ou pour le GPU. Les performances du GPU étaient alors grandement améliorées.
: Pour plus d'informations techniques sur ce cache, voici un lien qui explique son fonctionnement : [https://chipsandcheese.com/p/broadwells-edram-vcache-before-vcache Broadwell’s eDRAM: VCache before VCache was Cool].
==L'accès au cache==
Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur.
Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard.
[[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]]
La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là.
[[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]]
===Les succès et défauts de caches===
Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM.
Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général.
La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas).
Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles.
Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours.
===Le fonctionnement du cache, vu du processeur===
Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets.
Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés.
Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés.
[[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]]
===La situation avec une hiérarchie de caches===
Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas, on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire.
[[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]]
Quelques processeurs ajoutent un micro-cache L0, appelé le '''''filter cache''''', avant le cache L1. Le cache L0 contient à peine 8-16 lignes de cache maximum. Son existence n'est pas justifiée par une question de performance, même si sa consultation est très rapide. En réalité, il vise à réduire la consommation d'énergie du cache L1. Un succès dans le ''filter cache'' fait qu'on n'a pas à accéder à un cache L1 plus gros, dont l'accès consommerait plus d'électricité. Accéder à un cache L0 de quelques lignes de cache ne consomme quasiment rien, mais économise beaucoup en cas de succès de cache.
==La performance des mémoires caches==
L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache.
===Le taux de succès/défaut===
Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à :
: <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math>
Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à :
: <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math>
Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas.
Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes.
Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres.
Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena.
{{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}}
===La latence moyenne d'un cache===
Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>.
En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors :
: <math>T = T_c + \text{Taux de défaut} \times T_m</math>
On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs.
Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants).
===L'impact de la taille du cache sur le taux de défaut et la latence===
Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches.
Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge.
Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi :
: <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté.
Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache.
L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment :
: <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations.
Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations.
Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide.
La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long.
==Les lignes de cache et leurs tags==
Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes.
===Les lignes de cache===
Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM.
En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille.
Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples.
===L'alignement des lignes de cache===
Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne.
Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache.
L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants.
L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Si une données, comme un entier ou un flottant, est à cheval sur deux lignes de cache, elle sera chargée en deux fois, en deux lectures. idem pour les écritures.
Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique.
===Le tag d'une ligne de cache===
Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''.
Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante.
[[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]]
Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée.
[[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]]
===Le contenu d'une ligne de cache===
Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré.
[[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]]
Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''.
Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache.
Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''.
Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre.
Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs.
[[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]]
: Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples.
==Les instructions de contrôle du cache==
Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation.
===Les instructions de préchargement===
La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse.
L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique.
Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles.
===Les instructions d'invalidation et de ''flush''===
Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions.
Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache.
Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute.
Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point.
Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2.
Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''.
===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées===
Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache.
D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''.
L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles.
Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire.
Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc.
==L'associativité des caches et leur adressage implicite==
Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie.
===Les caches totalement associatifs===
Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches.
[[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]]
Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible.
[[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]]
Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur.
[[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]]
Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM.
[[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]]
Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs.
[[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]]
===Les caches directement adressés===
Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion.
[[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]]
Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer.
Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous.
[[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]]
Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache.
[[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]]
Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire.
[[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]]
L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches.
[[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]]
===Les caches associatifs par voie===
Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie.
[[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]]
Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''.
[[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]]
Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs.
[[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]]
Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits.
==Les optimisations des caches associatifs par voie==
Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations.
===Les caches pseudo-associatifs===
Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370.
Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie.
L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache.
===La prédiction de voie===
Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité.
Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie.
Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste.
Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags.
Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée :
* soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ;
* soit par l'adresse à accéder (là encore, quelques bits de poids faible) ;
* soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ;
* soit par autre chose.
===La mise en veille sélective des voies===
Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes.
Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue.
===Les caches ''skew-associative''===
Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais).
Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie.
[[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]]
==L'adressage physique ou logique des caches==
Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second.
{|
|[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]]
|[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]]
|}
===L'accès à un cache physiquement/virtuellement tagué===
La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds.
Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides.
Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs.
[[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]]
===Les défauts des caches virtuellement tagués===
Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes.
====Les droits d'accès doivent être vérifiés lors d'un accès au cache====
Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs.
Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable.
====Les adresses homonymes perturbent la gestion du cache====
Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser.
Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur.
Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur.
Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors.
L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU.
====Les adresses synonymes perturbent aussi la gestion du cache====
La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare !
Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes.
===Les caches virtuellement adressés, mais physiquement tagués===
Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies).
L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse.
Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache.
L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''.
[[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]]
Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice.
La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance.
==Le remplacement des lignes de cache==
Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie.
Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples.
Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1.
Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache.
===Le remplacement pseudo-aléatoire===
Le algorithme est aussi simple que contre-intuitif : la donnée effacée du cache est choisie au hasard ! On parle alors de '''remplacement pseudo-aléatoire'''. C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser peu de portes logiques. Ces avantages font qu'il a été utilisé sur quelques processeurs ARM basse consommation. Par exemple, les processeurs ARM Cortex A53 utilisait un tel algorithme, à la fois pour son cache d'instruction et son cache de données L1. Il s'agissait de processeurs basse consommation, au budget en transistors limité, qui n'avaient pas besoin de performances exceptionnelles.
L'avantage principal de cet algorithme est qu'il utilise très peu de portes logiques. Un générateur de nombres pseudo-aléatoire est un circuit assez simple : un simple registre à décalage à rétroaction linaire (LSFR) suffit. Le LSFR donne le numéro de la ligne de cache à évincer, il est "incrémenté"/mis à jour à chaque défaut de cache. Il n'y a pas besoin de mémoriser de méta-données pour savoir si telle ou telle ligne de cache a été récemment utilisée, comme ce sera le cas sur les algorithmes qui vont suivre.
Pour ce qui est des performances, elles sont acceptables, sous certaines conditions. Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. De plus, les économies de circuits faites sur le remplacement des lignes de cache peuvent être utilisées pour grossir le cache. Par exemple, au lieu d'avoir un cache de 64 kibioctets avec un algorithme complexe, on peut passer à 96 kibioctets avec remplacement pseudo-aléatoire. La capacité supplémentaire aide à réduire les performances moyennes du remplacement pseudo-aléatoire.
===FIFO : first in, first out===
Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire.
[[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]]
Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''.
===MRU : most recently used===
Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée.
Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables.
===LFU : least frequently used===
Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur.
[[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]]
===LRU : least recently used===
Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles.
Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU.
===Les approximations du LRU===
Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants.
L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies.
Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement.
Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture.
{|
|[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]]
|[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]]
|}
===LRU amélioré===
L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent.
Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur.
D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation.
==Les écritures dans le cache : gestion et optimisations==
Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''.
Avec un '''cache ''write-back''''', si une écriture a lieu dans une ligne de cache, elle ne concerne que ce cache. Elle n'est pas propagée dans les niveaux de cache inférieurs, ni en RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles.
[[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]]
Avec les '''caches ''Write-Through''''', toute écriture dans le cache est propagée dans les niveaux de cache inférieurs s'ils existent, puis en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont beaucoup plus simples à implémenter. Ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs.
[[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]]
===Les caches ''Write-through''===
Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps. Cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Et le même problème survient quand les niveaux de cache inférieurs sont utilisés en écriture. L'écriture ne peut pas être propagée tant que le port d'écriture est occupé.
Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures. C'est une mémoire FIFO dans laquelle on place temporairement les écritures, à savoir : les données à écrire en RAM, mais aussi l'adresse à laquelle écrire. Les écritures attendent dans le tampon d'écriture que les niveaux de cache inférieurs (ou la RAM) soient libres. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture.
Par souci d'efficacité, des écritures à la même adresse sont fusionnées en une seule, dans le tampon d’écriture. Cela fait un peu de place dans le tampon d’écriture, ce qui lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''.
L'usage d'un tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. Mais on peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''.
Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO.
===Les caches ''Write-back''===
Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM.
En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer'').
[[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]]
Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''.
Le cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus.
[[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]]
Quelques processeurs des années 2010 avaient un cache L3 qui était un cache de victime. Les défauts à froid chargeaient la donnée associée dans le cache L2, plus rarement dans le cache L1 directement. En tout cas, les données ne passaient pas par le cache L3. Le seul moyen pour qu'une donnée atterrissent dans le cache L3 est qu'elles aient été évincées du cache L2. C'était une solution adaptée pour une époque où avoir un gros cache était primordial, mais où le budget en transistor était un peu serré. Faire du L3 un cache de victime économisait des transistors, qui pouvaient être utilisés pour faire grossir le cache. De nos jours, les budgets en transistors permettent de faire autrement.
===La configuration du fonctionnement du cache===
Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture.
Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau.
Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée.
Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe.
De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec.
===L’allocation sur écriture===
Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement.
L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''.
[[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]]
Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs.
[[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]]
===La cohérence des caches===
Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache.
Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour.
==Les caches exclusifs et inclusifs==
Au début du chapitre, nous avons abordé le fait qu'un processeur moderne intégre une hiérarchie de mémoire caches, avec un cache L1, un cache L2 et un cache L3. Il est intéressant de regarder comment sont réaprties les données dans ces différents niveaux de cache. Et cela nous amène à parler des caches inclusifs et exclusifs.
Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple.
{|
|[[File:Exclusivecache.png|vignette|upright=0.5|Cache exclusif.]]
|[[File:Inclusivecache.png|vignette|upright=0.5|Cache inclusif.]]
|}
Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps.
[[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]]
Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches.
[[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]]
Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème.
Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds.
Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques.
==Le ''cache bypassing'' : contourner le cache==
Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant.
===Accéder aux périphériques demande de contourner le cache===
Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire.
La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes.
Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si.
Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable.
Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci.
===Contourner le cache pour des raisons de performance===
Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard).
Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache.
L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non.
===Le cas particulier du processeur MIPS R8000===
Le processeur MIPS R8000 intégrait une forme de ''cache bypassing'', qui est, à ma connaissance, totalement inédite. Et pour la comprendre, faisons un léger rappel. Les processeurs qui supportent les nombres flottants disposent d'instructions de lecture/écriture spécialisées pour les nombres flottants, séparées des instructions LOAD et STORE classiques. Elles sont souvent appelées FLOAD et STORE. Les fait de séparer les instructions LOAD/STORE des instructions FLOAD/FSTORE permet d'implémenter des optimisations, ce qui est le cas ici.
Sur le MIPS R8000, les instructions FLOAD et FSTORE contournaient le cache, à savoir que les flottants étaient lus/écrits sans passer par la mémoire cache. La raison à cela est que les applications de l'époque avaient une bonne localité spatiale et temporlle pour les instructions LOAD et STORE classiques, mais une très mauvaise localité spatiale et temporelle pour leurs équivalents flottant. Concrètement, les applications faisant beaucoup de calculs flottants profitaient peu de la présence de mémoire cache. Si les flottants passaient dans le cache, ils pollueraient le cache, à savoir qu'ils remplaceraient des données bien plus utiles.
Les choses sont cependant compliquées par la hiérarchie de cache un peu bizarre de ce processeur. Le processeur MIPS R8000 avait deux caches L1, un pour les données et un pour les instructions. De plus, il pouvait utiliser un cache L2 externe, placé sur la carte mère. Les instructions LOAD et STORE ne contournaient pas les caches L1 et L2, ce qui fait que les nombres entiers et adresses pouvaient sans problème être copiés dans le cache L1 de données. Les instructions FLOAD et FSTORE ne passaient pas par le cache L1, mais atterrissaient dans le cache L2. En clair, le cache L1 de données était contourné, mais pas le cache L2. Toute lecture/écriture flottante se faisait dans le cache L2 externe, elle contournait automatiquement le cache L1.
Comme toute forme de ''cache bypassing'', le MIPS R8000 devait faire face à des problèmes de cohérence des caches, qui apparaissaient des des situations bien spécifiques. Les problèmes survenaient quand des entiers et des flottants étaient mixés entre dans dans une même structure de donnée (voire quand un mot contenait indifféremment entier et flottant, grâce à l'usage d'unions). Une écriture flottante pouvait modifier un mot mémoire, sans que la copie dans le cache L1 soit modifiée. Idem avec une écriture entière, qui pouvait modifier un mot dans le cache L1, sans que le cache L2 soit au courant.
Pour résoudre le problème, les écritures flottants étaient partiellement prises en compte par le cache L1. Le flottant écrit n'était pas écrit dans le cache L1, mais l'adresse de l'écriture était envoyée au cache L1, pour que celui-ci vérifie s'il détient une copie de cette adresse. Si c'est le cas, la ligne de cache correspondante était invalidée. Pour gérer ces invalidations, chaque ligne de cache était associée à un bit de validité, qui était mis à 1 lorsque la donnée était chargée depuis la mémoire RAM. Les écritures flottantes mettaient à 0 ce bit de validité, les écritures entières le remettaient à 1.
==Les caches splittés (''phased caches'')==
Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Nous allons partir du principe que ce sont des caches ''direct-mapped'', mais l'optimisation peut être utilisée sur un cache associatif par voie.
L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs.
Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache.
Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin.
L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres.
Un autre avantage est que les caches splittés se marient asse bien avec les caches externes, et cela mérite d'en parler plus en détail.
===Les caches splités externes===
Les caches splittés sont surtout utilisés par des caches externes, situé en-dehors du processeur. Intuitivement, on se dit que les ''tags'' et les données sont tous deux placés en dehors du processeur. Un exemple est celui du Pentium 2 pour ordinateurs mobiles, illustré ci-dessous, qui avait un cache L2 splitté, totalement en dehors du processeur, ''tags'' comme données. Et c'est paradoxalement une minorité, car elle ne profite pas vraiment de la séparation ''tags''/données.
[[File:Intel MMC1 arch.svg|centre|vignette|upright=2|Intel MMC1 architecture.]]
Une optimisation place les ''tags'' dans le processeur, alors que les données sont dans un cache externe. Un exemple est celui du cache L2 du processeur UltraSPARC III. Son cache L1 était un cache normal, non-splitté. Le cache était splitté : les ''tags'' étaient dans le processeur, alors que les données étaient dans un cache situé en-dehors du processeur, sur la carte mère. Le fait d'intégrer les ''tags'' dans le processeur faisait que consulter les ''tags'' était très rapide, accéder aux données était plus lent. En contrepartie, la SRAM des données était bien plus grosse.
Intégrer les ''tags'' dans le CPU et les données dans un demi-cache externe permet de profiter de la séparation entre ''tags'' et données. Un cache de dernier niveau a un budget en transistors limité. Un processeur normal va l'utiliser pour les ''tags'' et les données. Mais avec un cache splitté, on peut l'utiliser totalement pour les ''tags'', alors que les données sont déportées en dehors du processeur. Le cache de dernier niveau gagne donc fortement en capacité, au prix d'un déficit au niveau du temps d'accès, vu que les données sont lues depuis l'extérieur du processeur.
===Le contrôleur de cache 82385 pour les CPU Intel 386===
Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible.
Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur.
Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures.
[[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]]
Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture.
Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits.
[[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]]
Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entrée-sortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache.
Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat.
Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système.
Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ça, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées.
===L'exemple des processeurs Intel des microarchitecture ''Broadwell'' et ''Skylake''===
Un autre exemple est celui du cache L4 des processeurs Broadwell. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées :
* une puce pour le processeur proprement dit ;
* une puce nommée ''Crystal Well'' contenant 128 mébioctets de mémoire eDRAM ;
* et une puce IO pour la communication avec la RAM et la carte mère.
Le cache L4 du processeur était un cache splitté : les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' contenait quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes.
Sur les processeurs de microarchitecture ''Broadwell'', le cache L4 était un cache de victime, donc non-inclusif. Les ''tags'' du cache L4 étaient placés dans le CPU. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. Vu que l'accès au L4 commençait en parallèle de l'accès au L3, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part.
Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur, dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé plus efficacement par la carte graphique intégrée au CPU. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags.
===Les caches RAM-configurables===
Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable.
[[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]]
===La compression de cache===
Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs.
Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache).
Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ?
[[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]]
Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée.
Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux :
* [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec].
==Les caches adressés par somme et hashés==
Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme.
Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement.
Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse.
[[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]]
Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante.
[[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]]
Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit :
: <math>A + B = K</math>
Ce qui est équivalent à faire le test suivant :
: <math>A + B - K = 0</math>
En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a :
: <math>A + B + \overline{K} + 1 = 0</math>
En réorganisant les termes, on a :
: <math>A + B + \overline{K} = - 1</math>
Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a:
: <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>.
Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux.
: <math>S + (R << 1) = 111 \cdots 111111</math>
[[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]]
Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors :
: <math>S \oplus (R << 1) = 111 \cdots 111111</math>
La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester.
Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances.
[[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]]
En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable.
==Les caches à accès uniforme et non-uniforme==
Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres.
Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme.
[[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]]
Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre
Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme.
Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe.
Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant.
Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA'').
Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique.
==La tolérance aux erreurs des caches==
Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens.
===Les mémoires caches ECC et à bit de parité===
Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé.
Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits.
La gestion de l'ECC est différente suivant le niveau de cache. Par exemple, il y a une différence entre cache d'instruction et de données. Les caches d'instruction utilisent un bit de parité, alors que les caches de données utilisent l'ECC. La raison est que si une instruction est corrompue par un ''bitflip'', on peut juste la relire depuis le cache L2, voire la mémoire RAM. Pour les données, les choses sont plus compliquées, il n'y a pas forcément de copie à jour de la donnée dans les niveaux de cache inférieurs. Et les caches ''Write-Through'' et ''Write-Back'' ne sont pas égaux du point de vue de l'ECC.
Avec les caches ''write-back'' et/ou exclusifs, quand une donnée est modifiée dans le cache, seul le cache a une copie de la donnée valide. Les niveaux en-dessous n'ont pas de copie de sauvegarde de la donnée, seul le cache L1 a la donnée à jour. Ils ont donc intérêt à protéger cette donnée contre les erreurs, ce qui implique l'usage de l'ECC au minimum.
A l'opposé, les caches ''Write-Through'' n'ont pas ce problème : ils garantissent que toute donnée dans le cache est aussi recopiée ailleurs, que ce soit en mémoire RAM, dans le niveau de cache inférieur, ou dans un ''write buffer''/tampon d'écriture. En conséquence, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Si une ligne de cache L1 est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Le cache étant petit, les corruptions de données sont assez rares et les défauts de cache induits le sont aussi. De plus, cela entraine des économies substantielles de circuits.
Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC.
En général, les processeurs préfèrent utiliser des caches ''Write-Back'' pour tous les niveaux, L1, L2 et L3, voire L4. Mais quelques rares processeurs préféraient utiliser des ''Write-Through'' pour le niveau L1, et seulement celui-ci. Un exemple est le Pentium 4 d'Intel, qui se débrouillait avec un L1 ''Write-Through'', sans tampon d'écriture. Les performances en écritures étaient de fait très mauvaises, du fait de l'absence de tampon d'ériture. Les processeurs AMD de microarchitecture Bulldozer avaient eu aussi un cache L1 ''Write-Through'', mais combinés avec un tampon d'écriture, pour conserver des performances acceptables. Le tampon d'écriture en question était un cache de 4 kibioctets, à 4 voies. Les performances du cache en écriture étaient cependant inférieures à celles des processeurs concurrents, qui utailisaient des caches ''Write-Back''
===L'usage du ''memory scrubbing'' sur les caches===
La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance.
Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire.
Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable.
==Un exemple de cache : le cache d'instruction==
[[File:Separate unified.png|thumb|Cache d'instruction séparé du cache de données.]]
La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions.
Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM.
Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre.
Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur.
[[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]]
===Pourquoi scinder le cache L1 en cache d'instruction et de données===
L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes.
Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches.
Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre.
Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives.
===La connexion des caches L1 avec le cache L2===
Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément.
[[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]]
Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé.
[[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]]
Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre !
===Les spécificités du cache d'instruction : lecture seule, bloquant, etc===
Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans.
Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés.
Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour.
Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale.
Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches.
===L'impact du cache d'instruction sur les performances===
Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions.
: La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale.
La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données.
D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Compléments sur les mémoires de masse
| prevText=Compléments sur les mémoires de masse
| next=Le préchargement
| nextText=Le préchargement
}}
</noinclude>
3g0wls17rb508fs64xvo9runsbixkd8
775314
775313
2026-10-15T01:13:01Z
Mewtow
31375
/* La situation avec une hiérarchie de caches */
775314
wikitext
text/x-wiki
La '''mémoire cache''' est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Sans elle, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire RAM est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instructions (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente.
==Les caches internes ou externes (au processeur)==
Les tout premiers processeurs utilisaient un cache externe, séparé du processeur. Mais de nos jours, le cache est intégré dans le processeur, directement. La loi de Moore a permis d'utiliser des transistors pour le cache, en plus des cœurs du processeur. Et cela nous amène à distinguer deux types de caches : les caches externes et internes. Les '''caches externes''' sont des caches qui sont à l'extérieur du processeur. Ils sont soudés sur la carte mère, ou ont leur propre connecteur, les deux sont possibles. À l'inverse, les '''caches internes''' sont intégrés dans le processeur, à l'intérieur de celui-ci.
Intégrer le cache dans le processeur a divers avantages. Le premier est que le cache est plus rapide quand il est dans le processeur que dehors. Les transferts entre cache et processeurs restent dans le processeur, il n'y a pas besoin de passer dans des interconnexions avec l'extérieur, qui sont assez lentes. Le cache a aussi tendance à aller à la même fréquence que le processeur, même s’il faut nuancer, comme on le verra plus bas.
Les caches internes sont tous fabriqués avec de la mémoire SRAM, la seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Pour les caches externes, on pourrait d'attendre à ce qu'ils soient faits en mémoire DRAM, mais c'est assez rare. En général, ils sont faits avec de la mémoire SRAM, comme les caches internes. Il y a cependant quelques exceptions, des caches externes qui utilisaient de la mémoire eDRAM.
===Les caches internes et la hiérarchie de caches===
L'intégration du cache dans le processeur ne suffit plus de nos jours. Les processeurs sont devenus tellement rapides qu'ils ne peuvent plus se contenter d'un seul cache ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Et qui dit même problème, dit même solution !
Les processeurs modernes utilisent une hiérarchie de mémoires caches, allant d'un cache très petit mais très rapide, à un cache plus lent mais de grande capacité. Les processeurs assez anciens n'utilisaient pas de cache L3, ils avaient juste un cache L1 petit et rapide, et un cache L2 gros et lent.
[[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]]
Depuis les années 2010, les processeurs utilisent une hiérarchie à trois niveaux de cache. Les caches en question sont appelés le '''cache L1''' pour le plus petit et le plus rapide, le '''cache L2''' pour le cache intermédiaire, et le '''cache L3''' pour le cache gros et lent. Il arrive même que certains processeurs aient un '''cache L4''', très gros mais aussi très lent.
[[File:Cache Hierarchy.png|centre|vignette|upright=1.5|Hiérarchie de caches]]
La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un '''cache L1 de données''' pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache L2 mémorise indifféremment données et instructions. La séparation entre cache L1 de données et d’instruction est apparue en même temps que la scission entre cache L1 et L2.
[[File:Cache d'instructions.png|centre|vignette|upright=2|Cache d'instructions.]]
Le nombre de niveaux de cache varient d'un processeur à l'autre. Les tout premiers processeurs faisaient avec un cache unique, sans hiérarchie de cache. Mais par la suite, la scission entre L1 et L2 est apparue. Puis un cache L3 a été ajouté, avec parfois un cache L4. Pour rester général, la hiérarchie de caches est composée de plusieurs '''niveaux de cache''', qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides.
Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Concrètement, c'est le cache L2 sur les anciens processeurs, le cache L3 ou L4 sur les processeurs modernes. Le cache de dernier niveau a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicœurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie du contrôleur mémoire ou de l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. Il est fréquent que le cache de dernier niveau ailler à une fréquence plus basse que celle du processeur. Le Pentium 2 avait par exemple un cache de dernier niveau qui allait à la moitié de la fréquence du CPU.
Petit point terminologie avant de poursuivre : j'utiliserais parfois l'expression "niveaux de cache inférieurs". L'expression se fait quand on parle d'un niveau de cache bien précis, souvent le niveau L1. Quand on parle des "niveaux de cache inférieurs au L1", cela veut dire le cache L2 et L3, voire ceux en dessous s'ils existent. Idem pour les "niveaux de cache inférieurs au L2", qui désigne le cache L3. La mémoire RAM est souvent implicitement comptée dans cette expression, même si elle n'est pas un cache à proprement parler.
===Les caches externes : les ''Cache On A STick''===
[[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]]
Les processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache On A STick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais ils étaient fabriqués en mémoire SRAM.
L'avantage d'un cache externe est que cela permettait de mettre plus de cache, à une époque où le budget en transistor était limité. Un autre avantage est que le consommateur pouvait choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était aussi possible d'upgrader le cache si besoin.
Pour les concepteurs de processeur, c'était aussi un moyen d'économiser des transistors, pour privilégier un maximum le processeur lui-même. Par exemple, c'est la raison pour laquelle le processeur PA 7100 n’intégrait aucun cache et devait se débrouiller avec un cache externe. Derrière ce choix se cachait une volonté d'utiliser un maximum de transistors pour le processeur proprement dit. Les modèles ultérieurs, comme le PA 7300, intégraient des caches L1, mais le L2 était déporté hors du CPU. Le choix d'HP était d'avoir un unique niveau de cache, optimisé de manière à pouvoir être à la fois rapide et de grande capacité.
Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Les ''Cache on a stick'' ont été introduits sur les processeurs Motorola, et utilisés sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la plupart des modèles. Les processeurs PA RISC de HP utilisaient eux aussi des caches externes. Les premiers processeurs x86 d'Intel avaient un cache L2 externe, de type ''Cache on a stick'', et ils vont nous intéresser dans ce qui suit.
Le Pentium 2 avait une organisation assez particulière. Le processeur était placé non pas sur un ''socket'', comme la quasi-totalité des processeurs, mais sur une carte fille qu'on clipsait sur la carte mère. La carte fille ressemblait à une barrette de RAM de grande taille, sur laquelle était soudé/connecté le processeur. Et le cache L2 était placé sur la carte fille. Ce n'était donc pas vraiment du ''cache on a stock'', mais le processeur était séparé du cache, les deux étaient dans des circuits séparés.
[[File:Intel Pentium II 400 SL357 SECC2.jpg|centre|vignette|upright=2|Pentium 2 : carte fille. Le processeur est au centre, le cache correspond aux deux circuits à droite.]]
Une différence avec les caches internes est qu'il n'y a pas de hiérarchie de cache avec les caches externes. Un cache externe est, par définition, un cache de dernier niveau. Le processeur peut avoir un cache L1, et utiliser un cache externe qui sera alors un cache L2. Ou encore, un processeur peut utiliser une hiérarchie de cache L1-L2-L3, mais avoir un cache externe, qui sera de niveau L4.
Un cache externe est connecté au bus mémoire s'il existe, au bus système sinon. Il intercepte les lectures à destination de la mémoire RAM, et y répond s'il a une copie de la donnée demandée. Les lectures étaient servies soit par le cache s'il avait une copie de la donnée, par la RAM sinon. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. Pour les écritures, elles se font généralement à la fois dans la RAM et le cache externe.
[[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]]
===Les caches mi-internes, mi-externes : les caches eDRAM===
Les '''caches eDRAM''' sont des caches assez rares, à mi-chemin entre cache internet et externe. Si je dis à mi-chemin, c'est car tout dépend de ce que veut dire "en-dehors d'un processeur". La réponse peut paraitre évidente, mais elle ne l'est pas si le processeur utilise une organisation en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégrés séparés, mais interconnectés et soudés sur un même PCB carré. En retirant la céramique qui recouvre le processeur, on a bien plusieurs circuits.
De rares processeurs avaient plusieurs circuits intégrés sur le même PCB : un pour le processeur proprement dit, un autre pour les interconnexions avec l'extérieur, et un pour un cache externe. Le cache était donc dans le "processeur" au sens du machin carré qu'on insère dans un ''socket'' de carte mère, mais dans un circuit intégré séparé. D'où le fait que j'ai parlé de cache à mi-chemin entre interne et externe. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, que nous allons voir dans la suite du chapitre.
De tels caches étaient fabriqués avec de la '''mémoire eDRAM''' (''embedded Dynamic Random Access Memory''), une forme de mémoire DRAM un peu particulière, fabriquée avec les mêmes technologies que celles utilisées pour les circuits intégrés. Ils avaient une densité intéressante, de bonnes performances, mais devaient être rafraichis comme toute mémoire DRAM. C'était les seuls caches qui n'étaient pas fabriqués avec de la mémoire SRAM, les seuls à avoir besoin de rafraichissement régulier.
Un exemple est celui du cache L4 des processeurs Intel, de microarchitecture ''Broadwell''. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées :
* une puce pour le processeur proprement dit ;
* une puce nommée ''Crystal Well'' contenant 128 mébioctets de mémoire eDRAM ;
* et une puce IO pour la communication avec la RAM et la carte mère.
La puce ''Crystal Well'' était une mémoire eDRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication.
Le cache ''Crystal Well'' était utilisable à la fois par le processeur, mais aussi par le GPU intégré au processeur. Les processeurs de l'époque intégraient déjà une carte graphique rudimentaire, peu puissante, mais suffisante pour jouer à des jeux vidéos anciens, et aussi pour afficher le bureau de Windows/Linux/MacOS, etc. Le GPU lit et écrit des données en mémoire RAM, comme le CPU. Mais en général, il n'a pas accès aux caches L1 et L2 du processeur. Le cache L4 externe pouvait mémoriser indifféremment données pour le CPU ou pour le GPU. Les performances du GPU étaient alors grandement améliorées.
: Pour plus d'informations techniques sur ce cache, voici un lien qui explique son fonctionnement : [https://chipsandcheese.com/p/broadwells-edram-vcache-before-vcache Broadwell’s eDRAM: VCache before VCache was Cool].
==L'accès au cache==
Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur.
Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard.
[[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]]
La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là.
[[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]]
===Les succès et défauts de caches===
Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM.
Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général.
La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas).
Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles.
Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours.
===Le fonctionnement du cache, vu du processeur===
Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets.
Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés.
Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés.
[[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]]
===La situation avec une hiérarchie de caches===
Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas, on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire.
[[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]]
Quelques processeurs ajoutent un micro-cache L0, appelé le '''''filter cache''''', avant le cache L1. Le cache L0 contient à peine 8-16 lignes de cache maximum. Son existence n'est pas justifiée par une question de performance, même si sa consultation est très rapide. En réalité, il vise à réduire la consommation d'énergie du cache L1. Un succès dans le ''filter cache'' fait qu'on n'a pas à accéder à un cache L1 plus gros, dont l'accès consommerait plus d'électricité. Accéder à un cache L0 de quelques lignes de cache ne consomme quasiment rien, mais économise beaucoup en cas de succès de cache.
Le cache L1 répond généralement en un ou deux cycles d'horloge, alors que le cache L0 répond tellement rapidement que ce n'est même pas la peine d'en parler, c'est une affaire d'une fraction de nanosecondes, tout au plus. Le cache L0 indique un succès ou défaut de cache tellement rapidement que le cache L1 a tout le temps de faire un accès dans le même cycle d'horloge. D'oùù le fait que le cache L0 n'a aucun intérêt en matière de performance.
==La performance des mémoires caches==
L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache.
===Le taux de succès/défaut===
Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à :
: <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math>
Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à :
: <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math>
Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas.
Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes.
Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres.
Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena.
{{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}}
===La latence moyenne d'un cache===
Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>.
En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors :
: <math>T = T_c + \text{Taux de défaut} \times T_m</math>
On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs.
Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants).
===L'impact de la taille du cache sur le taux de défaut et la latence===
Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches.
Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge.
Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi :
: <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté.
Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache.
L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment :
: <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations.
Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations.
Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide.
La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long.
==Les lignes de cache et leurs tags==
Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes.
===Les lignes de cache===
Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM.
En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille.
Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples.
===L'alignement des lignes de cache===
Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne.
Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache.
L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants.
L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Si une données, comme un entier ou un flottant, est à cheval sur deux lignes de cache, elle sera chargée en deux fois, en deux lectures. idem pour les écritures.
Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique.
===Le tag d'une ligne de cache===
Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''.
Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante.
[[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]]
Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée.
[[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]]
===Le contenu d'une ligne de cache===
Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré.
[[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]]
Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''.
Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache.
Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''.
Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre.
Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs.
[[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]]
: Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples.
==Les instructions de contrôle du cache==
Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation.
===Les instructions de préchargement===
La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse.
L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique.
Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles.
===Les instructions d'invalidation et de ''flush''===
Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions.
Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache.
Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute.
Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point.
Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2.
Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''.
===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées===
Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache.
D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''.
L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles.
Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire.
Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc.
==L'associativité des caches et leur adressage implicite==
Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie.
===Les caches totalement associatifs===
Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches.
[[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]]
Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible.
[[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]]
Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur.
[[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]]
Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM.
[[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]]
Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs.
[[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]]
===Les caches directement adressés===
Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion.
[[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]]
Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer.
Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous.
[[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]]
Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache.
[[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]]
Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire.
[[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]]
L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches.
[[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]]
===Les caches associatifs par voie===
Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie.
[[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]]
Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''.
[[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]]
Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs.
[[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]]
Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits.
==Les optimisations des caches associatifs par voie==
Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations.
===Les caches pseudo-associatifs===
Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370.
Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie.
L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache.
===La prédiction de voie===
Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité.
Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie.
Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste.
Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags.
Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée :
* soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ;
* soit par l'adresse à accéder (là encore, quelques bits de poids faible) ;
* soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ;
* soit par autre chose.
===La mise en veille sélective des voies===
Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes.
Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue.
===Les caches ''skew-associative''===
Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais).
Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie.
[[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]]
==L'adressage physique ou logique des caches==
Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second.
{|
|[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]]
|[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]]
|}
===L'accès à un cache physiquement/virtuellement tagué===
La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds.
Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides.
Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs.
[[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]]
===Les défauts des caches virtuellement tagués===
Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes.
====Les droits d'accès doivent être vérifiés lors d'un accès au cache====
Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs.
Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable.
====Les adresses homonymes perturbent la gestion du cache====
Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser.
Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur.
Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur.
Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors.
L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU.
====Les adresses synonymes perturbent aussi la gestion du cache====
La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare !
Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes.
===Les caches virtuellement adressés, mais physiquement tagués===
Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies).
L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse.
Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache.
L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''.
[[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]]
Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice.
La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance.
==Le remplacement des lignes de cache==
Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie.
Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples.
Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1.
Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache.
===Le remplacement pseudo-aléatoire===
Le algorithme est aussi simple que contre-intuitif : la donnée effacée du cache est choisie au hasard ! On parle alors de '''remplacement pseudo-aléatoire'''. C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser peu de portes logiques. Ces avantages font qu'il a été utilisé sur quelques processeurs ARM basse consommation. Par exemple, les processeurs ARM Cortex A53 utilisait un tel algorithme, à la fois pour son cache d'instruction et son cache de données L1. Il s'agissait de processeurs basse consommation, au budget en transistors limité, qui n'avaient pas besoin de performances exceptionnelles.
L'avantage principal de cet algorithme est qu'il utilise très peu de portes logiques. Un générateur de nombres pseudo-aléatoire est un circuit assez simple : un simple registre à décalage à rétroaction linaire (LSFR) suffit. Le LSFR donne le numéro de la ligne de cache à évincer, il est "incrémenté"/mis à jour à chaque défaut de cache. Il n'y a pas besoin de mémoriser de méta-données pour savoir si telle ou telle ligne de cache a été récemment utilisée, comme ce sera le cas sur les algorithmes qui vont suivre.
Pour ce qui est des performances, elles sont acceptables, sous certaines conditions. Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. De plus, les économies de circuits faites sur le remplacement des lignes de cache peuvent être utilisées pour grossir le cache. Par exemple, au lieu d'avoir un cache de 64 kibioctets avec un algorithme complexe, on peut passer à 96 kibioctets avec remplacement pseudo-aléatoire. La capacité supplémentaire aide à réduire les performances moyennes du remplacement pseudo-aléatoire.
===FIFO : first in, first out===
Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire.
[[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]]
Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''.
===MRU : most recently used===
Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée.
Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables.
===LFU : least frequently used===
Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur.
[[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]]
===LRU : least recently used===
Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles.
Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU.
===Les approximations du LRU===
Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants.
L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies.
Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement.
Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture.
{|
|[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]]
|[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]]
|}
===LRU amélioré===
L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent.
Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur.
D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation.
==Les écritures dans le cache : gestion et optimisations==
Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''.
Avec un '''cache ''write-back''''', si une écriture a lieu dans une ligne de cache, elle ne concerne que ce cache. Elle n'est pas propagée dans les niveaux de cache inférieurs, ni en RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles.
[[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]]
Avec les '''caches ''Write-Through''''', toute écriture dans le cache est propagée dans les niveaux de cache inférieurs s'ils existent, puis en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont beaucoup plus simples à implémenter. Ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs.
[[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]]
===Les caches ''Write-through''===
Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps. Cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Et le même problème survient quand les niveaux de cache inférieurs sont utilisés en écriture. L'écriture ne peut pas être propagée tant que le port d'écriture est occupé.
Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures. C'est une mémoire FIFO dans laquelle on place temporairement les écritures, à savoir : les données à écrire en RAM, mais aussi l'adresse à laquelle écrire. Les écritures attendent dans le tampon d'écriture que les niveaux de cache inférieurs (ou la RAM) soient libres. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture.
Par souci d'efficacité, des écritures à la même adresse sont fusionnées en une seule, dans le tampon d’écriture. Cela fait un peu de place dans le tampon d’écriture, ce qui lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''.
L'usage d'un tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. Mais on peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''.
Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO.
===Les caches ''Write-back''===
Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM.
En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer'').
[[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]]
Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''.
Le cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus.
[[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]]
Quelques processeurs des années 2010 avaient un cache L3 qui était un cache de victime. Les défauts à froid chargeaient la donnée associée dans le cache L2, plus rarement dans le cache L1 directement. En tout cas, les données ne passaient pas par le cache L3. Le seul moyen pour qu'une donnée atterrissent dans le cache L3 est qu'elles aient été évincées du cache L2. C'était une solution adaptée pour une époque où avoir un gros cache était primordial, mais où le budget en transistor était un peu serré. Faire du L3 un cache de victime économisait des transistors, qui pouvaient être utilisés pour faire grossir le cache. De nos jours, les budgets en transistors permettent de faire autrement.
===La configuration du fonctionnement du cache===
Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture.
Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau.
Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée.
Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe.
De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec.
===L’allocation sur écriture===
Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement.
L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''.
[[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]]
Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs.
[[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]]
===La cohérence des caches===
Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache.
Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour.
==Les caches exclusifs et inclusifs==
Au début du chapitre, nous avons abordé le fait qu'un processeur moderne intégre une hiérarchie de mémoire caches, avec un cache L1, un cache L2 et un cache L3. Il est intéressant de regarder comment sont réaprties les données dans ces différents niveaux de cache. Et cela nous amène à parler des caches inclusifs et exclusifs.
Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple.
{|
|[[File:Exclusivecache.png|vignette|upright=0.5|Cache exclusif.]]
|[[File:Inclusivecache.png|vignette|upright=0.5|Cache inclusif.]]
|}
Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps.
[[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]]
Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches.
[[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]]
Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème.
Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds.
Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques.
==Le ''cache bypassing'' : contourner le cache==
Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant.
===Accéder aux périphériques demande de contourner le cache===
Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire.
La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes.
Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si.
Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable.
Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci.
===Contourner le cache pour des raisons de performance===
Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard).
Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache.
L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non.
===Le cas particulier du processeur MIPS R8000===
Le processeur MIPS R8000 intégrait une forme de ''cache bypassing'', qui est, à ma connaissance, totalement inédite. Et pour la comprendre, faisons un léger rappel. Les processeurs qui supportent les nombres flottants disposent d'instructions de lecture/écriture spécialisées pour les nombres flottants, séparées des instructions LOAD et STORE classiques. Elles sont souvent appelées FLOAD et STORE. Les fait de séparer les instructions LOAD/STORE des instructions FLOAD/FSTORE permet d'implémenter des optimisations, ce qui est le cas ici.
Sur le MIPS R8000, les instructions FLOAD et FSTORE contournaient le cache, à savoir que les flottants étaient lus/écrits sans passer par la mémoire cache. La raison à cela est que les applications de l'époque avaient une bonne localité spatiale et temporlle pour les instructions LOAD et STORE classiques, mais une très mauvaise localité spatiale et temporelle pour leurs équivalents flottant. Concrètement, les applications faisant beaucoup de calculs flottants profitaient peu de la présence de mémoire cache. Si les flottants passaient dans le cache, ils pollueraient le cache, à savoir qu'ils remplaceraient des données bien plus utiles.
Les choses sont cependant compliquées par la hiérarchie de cache un peu bizarre de ce processeur. Le processeur MIPS R8000 avait deux caches L1, un pour les données et un pour les instructions. De plus, il pouvait utiliser un cache L2 externe, placé sur la carte mère. Les instructions LOAD et STORE ne contournaient pas les caches L1 et L2, ce qui fait que les nombres entiers et adresses pouvaient sans problème être copiés dans le cache L1 de données. Les instructions FLOAD et FSTORE ne passaient pas par le cache L1, mais atterrissaient dans le cache L2. En clair, le cache L1 de données était contourné, mais pas le cache L2. Toute lecture/écriture flottante se faisait dans le cache L2 externe, elle contournait automatiquement le cache L1.
Comme toute forme de ''cache bypassing'', le MIPS R8000 devait faire face à des problèmes de cohérence des caches, qui apparaissaient des des situations bien spécifiques. Les problèmes survenaient quand des entiers et des flottants étaient mixés entre dans dans une même structure de donnée (voire quand un mot contenait indifféremment entier et flottant, grâce à l'usage d'unions). Une écriture flottante pouvait modifier un mot mémoire, sans que la copie dans le cache L1 soit modifiée. Idem avec une écriture entière, qui pouvait modifier un mot dans le cache L1, sans que le cache L2 soit au courant.
Pour résoudre le problème, les écritures flottants étaient partiellement prises en compte par le cache L1. Le flottant écrit n'était pas écrit dans le cache L1, mais l'adresse de l'écriture était envoyée au cache L1, pour que celui-ci vérifie s'il détient une copie de cette adresse. Si c'est le cas, la ligne de cache correspondante était invalidée. Pour gérer ces invalidations, chaque ligne de cache était associée à un bit de validité, qui était mis à 1 lorsque la donnée était chargée depuis la mémoire RAM. Les écritures flottantes mettaient à 0 ce bit de validité, les écritures entières le remettaient à 1.
==Les caches splittés (''phased caches'')==
Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Nous allons partir du principe que ce sont des caches ''direct-mapped'', mais l'optimisation peut être utilisée sur un cache associatif par voie.
L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs.
Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache.
Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin.
L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres.
Un autre avantage est que les caches splittés se marient asse bien avec les caches externes, et cela mérite d'en parler plus en détail.
===Les caches splités externes===
Les caches splittés sont surtout utilisés par des caches externes, situé en-dehors du processeur. Intuitivement, on se dit que les ''tags'' et les données sont tous deux placés en dehors du processeur. Un exemple est celui du Pentium 2 pour ordinateurs mobiles, illustré ci-dessous, qui avait un cache L2 splitté, totalement en dehors du processeur, ''tags'' comme données. Et c'est paradoxalement une minorité, car elle ne profite pas vraiment de la séparation ''tags''/données.
[[File:Intel MMC1 arch.svg|centre|vignette|upright=2|Intel MMC1 architecture.]]
Une optimisation place les ''tags'' dans le processeur, alors que les données sont dans un cache externe. Un exemple est celui du cache L2 du processeur UltraSPARC III. Son cache L1 était un cache normal, non-splitté. Le cache était splitté : les ''tags'' étaient dans le processeur, alors que les données étaient dans un cache situé en-dehors du processeur, sur la carte mère. Le fait d'intégrer les ''tags'' dans le processeur faisait que consulter les ''tags'' était très rapide, accéder aux données était plus lent. En contrepartie, la SRAM des données était bien plus grosse.
Intégrer les ''tags'' dans le CPU et les données dans un demi-cache externe permet de profiter de la séparation entre ''tags'' et données. Un cache de dernier niveau a un budget en transistors limité. Un processeur normal va l'utiliser pour les ''tags'' et les données. Mais avec un cache splitté, on peut l'utiliser totalement pour les ''tags'', alors que les données sont déportées en dehors du processeur. Le cache de dernier niveau gagne donc fortement en capacité, au prix d'un déficit au niveau du temps d'accès, vu que les données sont lues depuis l'extérieur du processeur.
===Le contrôleur de cache 82385 pour les CPU Intel 386===
Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible.
Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur.
Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures.
[[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]]
Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture.
Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits.
[[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]]
Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entrée-sortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache.
Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat.
Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système.
Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ça, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées.
===L'exemple des processeurs Intel des microarchitecture ''Broadwell'' et ''Skylake''===
Un autre exemple est celui du cache L4 des processeurs Broadwell. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées :
* une puce pour le processeur proprement dit ;
* une puce nommée ''Crystal Well'' contenant 128 mébioctets de mémoire eDRAM ;
* et une puce IO pour la communication avec la RAM et la carte mère.
Le cache L4 du processeur était un cache splitté : les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' contenait quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes.
Sur les processeurs de microarchitecture ''Broadwell'', le cache L4 était un cache de victime, donc non-inclusif. Les ''tags'' du cache L4 étaient placés dans le CPU. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. Vu que l'accès au L4 commençait en parallèle de l'accès au L3, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part.
Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur, dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé plus efficacement par la carte graphique intégrée au CPU. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags.
===Les caches RAM-configurables===
Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable.
[[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]]
===La compression de cache===
Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs.
Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache).
Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ?
[[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]]
Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée.
Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux :
* [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec].
==Les caches adressés par somme et hashés==
Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme.
Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement.
Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse.
[[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]]
Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante.
[[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]]
Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit :
: <math>A + B = K</math>
Ce qui est équivalent à faire le test suivant :
: <math>A + B - K = 0</math>
En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a :
: <math>A + B + \overline{K} + 1 = 0</math>
En réorganisant les termes, on a :
: <math>A + B + \overline{K} = - 1</math>
Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a:
: <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>.
Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux.
: <math>S + (R << 1) = 111 \cdots 111111</math>
[[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]]
Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors :
: <math>S \oplus (R << 1) = 111 \cdots 111111</math>
La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester.
Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances.
[[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]]
En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable.
==Les caches à accès uniforme et non-uniforme==
Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres.
Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme.
[[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]]
Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre
Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme.
Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe.
Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant.
Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA'').
Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique.
==La tolérance aux erreurs des caches==
Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens.
===Les mémoires caches ECC et à bit de parité===
Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé.
Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits.
La gestion de l'ECC est différente suivant le niveau de cache. Par exemple, il y a une différence entre cache d'instruction et de données. Les caches d'instruction utilisent un bit de parité, alors que les caches de données utilisent l'ECC. La raison est que si une instruction est corrompue par un ''bitflip'', on peut juste la relire depuis le cache L2, voire la mémoire RAM. Pour les données, les choses sont plus compliquées, il n'y a pas forcément de copie à jour de la donnée dans les niveaux de cache inférieurs. Et les caches ''Write-Through'' et ''Write-Back'' ne sont pas égaux du point de vue de l'ECC.
Avec les caches ''write-back'' et/ou exclusifs, quand une donnée est modifiée dans le cache, seul le cache a une copie de la donnée valide. Les niveaux en-dessous n'ont pas de copie de sauvegarde de la donnée, seul le cache L1 a la donnée à jour. Ils ont donc intérêt à protéger cette donnée contre les erreurs, ce qui implique l'usage de l'ECC au minimum.
A l'opposé, les caches ''Write-Through'' n'ont pas ce problème : ils garantissent que toute donnée dans le cache est aussi recopiée ailleurs, que ce soit en mémoire RAM, dans le niveau de cache inférieur, ou dans un ''write buffer''/tampon d'écriture. En conséquence, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Si une ligne de cache L1 est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Le cache étant petit, les corruptions de données sont assez rares et les défauts de cache induits le sont aussi. De plus, cela entraine des économies substantielles de circuits.
Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC.
En général, les processeurs préfèrent utiliser des caches ''Write-Back'' pour tous les niveaux, L1, L2 et L3, voire L4. Mais quelques rares processeurs préféraient utiliser des ''Write-Through'' pour le niveau L1, et seulement celui-ci. Un exemple est le Pentium 4 d'Intel, qui se débrouillait avec un L1 ''Write-Through'', sans tampon d'écriture. Les performances en écritures étaient de fait très mauvaises, du fait de l'absence de tampon d'ériture. Les processeurs AMD de microarchitecture Bulldozer avaient eu aussi un cache L1 ''Write-Through'', mais combinés avec un tampon d'écriture, pour conserver des performances acceptables. Le tampon d'écriture en question était un cache de 4 kibioctets, à 4 voies. Les performances du cache en écriture étaient cependant inférieures à celles des processeurs concurrents, qui utailisaient des caches ''Write-Back''
===L'usage du ''memory scrubbing'' sur les caches===
La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance.
Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire.
Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable.
==Un exemple de cache : le cache d'instruction==
[[File:Separate unified.png|thumb|Cache d'instruction séparé du cache de données.]]
La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions.
Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM.
Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre.
Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur.
[[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]]
===Pourquoi scinder le cache L1 en cache d'instruction et de données===
L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes.
Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches.
Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre.
Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives.
===La connexion des caches L1 avec le cache L2===
Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément.
[[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]]
Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé.
[[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]]
Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre !
===Les spécificités du cache d'instruction : lecture seule, bloquant, etc===
Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans.
Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés.
Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour.
Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale.
Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches.
===L'impact du cache d'instruction sur les performances===
Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions.
: La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale.
La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données.
D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Compléments sur les mémoires de masse
| prevText=Compléments sur les mémoires de masse
| next=Le préchargement
| nextText=Le préchargement
}}
</noinclude>
2q5gso7ht7lu7yruc2rags722zxgur2
Fonctionnement d'un ordinateur/Les circuits de calcul flottant
0
68985
775301
749169
2026-10-14T18:58:10Z
Mewtow
31375
/* Les flottants logarithmiques */
775301
wikitext
text/x-wiki
Après avoir vu les circuits de calcul pour les nombres entiers, il est temps de voir les circuits de calculs pour des nombres flottants. Nous allons nous concentrer sur les nombres flottants au format IEEE754, avant de faire un aparté sur les flottants logarithmiques.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Il est souvent dit qu'un processeur incorpore une unité de calcul spécialisée dans les calculs flottants, appelée la ''Floating Point Unit'', ce qui se traduirait en '''unité de calcul flottante'''. Dans la réalité, les processeurs modernes incorporent plusieurs circuits distincts : un pour multiplier deux flottants, un autre pour additionner deux flottants, et éventuellement un troisième pour la division flottante. Ils sont implémentés avec des circuits séparés, comme le sont les ALU et les circuits multiplieurs/diviseurs.
==Les multiplications/divisions flottantes==
Paradoxalement, les multiplications, divisions et racines carrées sont plus simples que l'addition, avec des nombres flottants. Aussi, nous allons d'abord parler des opérations de multiplications et divisions, avant de poursuivre avec les additions et soustractions, en enfin de terminer avec les procédés de normalisation, arrondis et prénormalisation.
Avant le calcul, il y a une étape de '''prénormalisation''', qui gère le bit implicite des mantisses. Elle détermine si ce bit vaut 0 (flottants dénormaux) ou 1 (les flottants normaux), puis l'ajoute aux mantisses. Pour la multiplication et la division, l'étape de prénormalisation ne fait pas autre chose. Mais pour l'addition et la soustraction, elle est plus complexe, comme on le verra plus tard.
===Les circuits multiplieurs/diviseurs flottants===
Prenons deux nombres flottants de mantisses <math>m_1</math> et <math>m_2</math> et les exposants <math>e_1</math> et <math>e_2</math>. Leur multiplication donne :
: <math>(m_1 \times 2^{e_1}) \times (m_2 \times 2^{e_2})</math>
On regroupe les termes :
: <math>(m_1 \times m_2) \times (2^{e_1} \times 2^{e_2})</math>
On simplifie la puissance :
: <math>(m_1 \times m_2) \times 2^{e_1 + e_2}</math>
En clair, multiplier deux flottants revient à multiplier les mantisses et additionner les exposants, ce qui demande un additionneur-soustracteur et un multiplieur. Il faut cependant penser à plusieurs choses pas forcément évidentes.
* Premièrement, il faut ajouter les bits implicites aux mantisses avant de les multiplier, ce qui est le rôle de l'étape de pré-normalisation.
* Deuxièmement, il faut se rappeler que les exposants sont encodés en représentation par excès, ce qui fait qu'il faut utiliser un additionneur-soustracteur en représentation par excès.
* Troisièmement, il faut calculer le bit de signe du résultat à partir de ceux des opérandes.
* Enfin, il ne faut pas oublier de rajouter les étapes de normalisation et d'arrondis, dont on parlera plus bas.
[[File:Multiplieur flottant avec normalisation.PNG|centre|vignette|upright=2|Multiplieur flottant avec normalisation]]
La division fonctionne sur le même principe que la multiplication, si ce n'est que les exposants sont soustraits et que les mantisses sont divisées. Pour le démontrer, prenons deux flottants <math>m_1 \times 2^{e_1}</math> et <math>m_2 \times 2^{e_2}</math> et divisons le premier par le second. On a alors :
: <math>\frac{m1 \times 2^{e_1}}{m2 \times 2^{e_2}}</math>
On applique les règles sur les fractions :
: <math>\frac{m_1}{m_2} \times \frac{2^{e_1}}{2^{e_2}}</math>
On simplifie la puissance de 2 :
: <math>\frac{m_1}{m_2} \times 2^{e_1-e_2}</math>
===La multiplication entière réalisée par l'unité de calcul flottante===
Vous remarquerez qu'un multiplieur flottant contient un multiplieur entier pour multiplier les mantisses. Sachez qu'il est possible de n'utiliser qu'un seul multiplieur entier pour faire à la fois les multiplications entières et flottantes. Cette optimisation a été utilisée sur plusieurs processeurs commerciaux. Par exemple, les processeurs Atom d'Intel utilisaient cette optimisation : les multiplications entières étaient réalisées dans un multiplieur entier partagé avec l'unité de calcul flottante. Il en est de même sur les processeurs Athlon d'AMD, sortis dans les années 2000.
Mais cela ne fonctionnait que sur les processeurs 32 bits, pas sur les 64 bits. Pour comprendre pourquoi, il faut savoir que les processeurs modernes gèrent des nombres flottants simple et double précision, à savoir codés sur 32 et 64 bits. Il y a un seul multiplieur, capable de gérer des flottants double précision, qui peut le plus peut le moins. Avec des flottants double précision de 64 bits, les mantisses font 52 bits, ce qui fait un multiplieur de 53 bits. C'est suffisant pour un processeur 32 bits, pas assez sur un processeur 64 bits.
Ajoutons cependant une petite nuance. Sur les anciens processeurs x86 des PC, les flottants faisaient 80 bits, avec une mantisse de 64 bits, ce qui est assez à la fois pour les processeurs 32 et 64 bits. Malheureusement, les processeurs 64 bits avaient un budget en transistor suffisant pour ne pas appliquer cette optimisation. Pour des raisons diverses, il était préférable d'avoir un multiplieur entier séparé du multiplieur flottant.
==L'addition et la soustraction flottante==
La somme de deux flottants se calcule simplement si les exposants des deux opérandes sont égaux : il suffit alors d'additionner les mantisses. Mais que faire si les deux exposants sont différents ? L'astuce est de mettre les deux flottants au même exposant sans en changer leur valeur, de les mettre à l'échelle. L'exposant choisi étant souvent le plus grand exposant des deux flottants. Une fois mises à l'échelle, les deux opérandes sont additionnées, et le résultat est normalisé pour donner un flottant.
Suivant les signes, il faudra additionner ou soustraire les opérandes : additionner une opérande positive avec une négative demande en réalité de faire une soustraction, de même que soustraire une opérande négative demande en réalité de l'additionner. Il faut donc ajouter, avant l'additionneur, un circuit qui détermine s'il faut faire une addition ou une soustraction, en fonction du bit de signe des opérandes, et de s'il faut faire une addition ou une soustraction (opcode de l'opération voulue).
[[File:Crcuit d'addition et de soustraction flottante.jpg|centre|vignette|upright=2|Circuit d'addition et de soustraction flottante.]]
===Le circuit de pré-normalisation===
La mise des deux opérandes au même exposant s'appelle la '''pré-normalisation'''. L'exposant final est choisit parmi les deux opérandes : on prend le plus grand exposant parmi des deux. L'opérande avec le plus grand exposant reste inchangée, elle est conservée telle quelle. Par contre, il faut pré-normaliser l'autre opérande, celui avec le plus petit exposant. Et pour cela, rien de plus simple : il suffit de décaler la mantisse vers la droite, d'un nombre de rangs égal à la différence entre les deux exposants.
Pour faire ce décalage, on utilise un décaleur et un circuit qui échange les deux opérandes. Le circuit d'échange a pour but d'envoyer le plus petit exposant dans le décaleur et est composé de quelques multiplexeurs. Il est piloté par un comparateur qui détermine quel est le nombre avec le plus petit exposant. Nous verrons comment fabriquer un tel comparateur dans le chapitre suivant sur les comparateurs.
[[File:Circuit de mise au même exposant.jpg|centre|vignette|upright=2|Circuit de mise au même exposant.]]
Précisons que le comparateur et le soustracteur peuvent être fusionnés, car un comparateur est en réalité un soustracteur amélioré. Une manière alternative est la suivante. En premier lieu, on soustrait les exposants pour déterminer de combien décaler la mantisse. Le résultat de la soustraction est ensuite envoyé à un circuit qui vérifie si le résultat est positif ou négatif, en vérifiant le bit de poids fort du résultat. Si le résultat est positif, la première opérande est plus grande que la seconde, c'est la seconde opérande qu'il faut pré-normaliser. Si le résultat est négatif, c'est la première opérande qu'il faut prénormaliser.
[[File:Circuit de prénormalisation d'un additionneur flottant.jpg|centre|vignette|upright=2|Circuit de prénormalisation d'un additionneur flottant]]
==La normalisation et les arrondis==
Calculer sur des nombres flottants peut sembler trivial, mais les mathématiques ne sont pas vraiment d'accord avec cela. En effet, le résultat d'un calcul avec des flottants n'est pas forcément un flottant valide. Il doit subir quelques transformations pour être un nombre flottant : il doit souvent être arrondi, et doit auissi passer par d'autres étapes dites de normalisation.
[[File:Normalisation in circuit.png|vignette|upright=1|Normalisation in circuit]]
Elles corrigent le résultat du calcul pour qu'il rentre dans un nombre flottant. Par exemple, si on multiplie deux flottants de 32 bits, l'exposant et la mantisse du résultat sont calculés séparément et les concaténer ne donne pas forcément un nombre flottant 32 bits. Diverses techniques de normalisation et d'arrondis permettent de corriger l'exposant et la mantisse pour donner un flottant 32 bit correct. Et elles auront leur section dédiée.
La normalisation et les arrondis sont gérés différemment suivant le format de flottant utilisé. Les flottants les plus courants suivent la norme IEEE754, où normalisation et arrondis sont standardisés. Mais d'autres formats de flottants exotiques peuvent suivre des règles différentes.
===La normalisation===
La '''normalisation''' gère le bit implicite. Le résultat en sortie d'un circuit de calcul n'a pas forcément son bit implicite à 1. Prenons l'exemple suivant, où on soustrait deux flottants qui ont des mantisses codées sur 8 bits - le format de flottant n'est donc par standard. On soustrait les deux mantisses suivantes, le chiffre entre parenthèse est le bit implicite : (1) 1100 1100 - (1) 1000 1000 = (0) 0100 0100.
Le résultat a un bit implicite à 0, ce qui donne un résultat dénormal. Mais il est parfois possible de convertir ce résultat en un flottant normal, à condition de corriger l'exposant. L'idée est, pour le cas précédent, de décaler la mantisse de deux rangs : (0) 0100 0100 devient (1) 0001 00''00''. Mais décaler la mantisse déforme le résultat : le résultat décalé de deux rangs vers la gauche multiplie le résultat par 4. Mais on peut compenser exactement le tout en corrigeant l'exposant, afin de diviser le résultat final par 4 : il suffit de soustraire deux à l'exposant !
Le cas général est assez similaire, sauf que l'on doit décaler la mantisse par un nombre de rang adéquat, pas forcément 2, et soustraire ce nombre de rangs à l'exposant. Pour savoir de combien de rangs il faut décaler, il faut compter le nombre de zéros situés de poids fort, avec un circuit spécialisé qu'on a vu il y a quelques chapitres, le circuit de CLZ (''Count Leading Zero''). Ce circuit permet aussi de détecter si la mantisse vaut zéro.
[[File:Circuit de prénormalisation.jpg|centre|vignette|upright=2|Circuit de normalisation.]]
===Les arrondis===
Une fois ce résultat calculé, il faut faire un arrondi du résultat avec un circuit d''''arrondi'''. L'arrondi se base sur les bits de poids faible situés juste à gauche et à droite de la virgule., ce qui demande d'analyser une dizaine de bits tout au plus. Une fois les bits de poids faible à gauche de la virgule sont remplacé, les bits à droite sont éliminés.
L'arrondi peut être réalisé par un circuit combinatoire, mais le faible nombre de bits d'entrée rend possible d'utiliser ce qui s'appelle une '''mémoire de précalcul'''. Avec cette technique, le circuit combinatoire est remplacé par une ROM équivalente. Les entrées du circuit combinatoire deviennent l'entrée d'adresse de la ROM, les sorties du circuit deviennent la sortie de donnée de la ROM. Pour une entrée identique, les deux donneront les mêmes sorties.
[[File:Circuit d'arrondi flottant basé sur une ROM.png|centre|vignette|upright=1.5|Circuit d'arrondi flottant basé sur une ROM.]]
Malheureusement, il arrive que ces arrondis décalent la position du bit implicite d'un rang, ce qui se résout avec un décalage si cela arrive. Le circuit de normalisation contient donc de quoi détecter ces débordements et un décaleur. Bien évidemment, l'exposant doit alors lui aussi être corrigé en cas de décalage de la mantisse.
[[File:Circuit de postnormalisation.jpg|centre|vignette|upright=2|Circuit de postnormalisation.]]
==La comparaison de deux flottants==
Nous venons de voir deux opérations arithmétiques : l'addition et la multiplication. Mais nous n'avons pas vu une opération moins intuitive ; les comparaisons ! Il est en effet possible de comparer deux nombres flottants, pour savoir si ils sont égaux, différents, si le premier est supérieur au second, inférieur au second, etc. La comparaison de deux flottants ne ressemble pas beaucoup à celle de deux nombres entiers. Le fait que les flottants sont composés d'un bit de signe, d'un exposant et d'une mantisse, avec un bit implicite, rend les comparaisons plus compliquées.
Une solution consiste à compare les bits de signe, les exposants et les mantisses séparément. Les exposants sont encodés avec des nombres entiers (en représentation par excès), idem pour les mantisses. Je viens de dire que les exposants et mantisses sont comparées séparément. Il est possible de les comparer en série : son test d'abord les exposants, et on ne compare les mantisses que s'ils sont égaux. Une autre technique compare les exposants et mantisses en parallèle, et le résultat pour les exposants prend le pas sur le reste.
La seule difficulté est de déterminer le bit implicite des mantisses, du moins semble-t-il. Sauf qu'il faut aussi prendre en compte les cas particuliers : les dénormaux, les NaNs, les infinis. Le comparateur flottant doit donc tester si les opérandes, pour vérifier si ce sont des NaNs, des infinis, un zéro, voire un dénormal. Dans ce cas, le résultat de la comparaison est déterminé par un circuit séparé, qui traite ces cas particuliers.
==Les flottants logarithmiques==
Maintenant, nous allons fabriquer une FPU pour les flottants logarithmiques. Nous avions vu les flottants logarithmiques dans le chapitre [[Fonctionnement d'un ordinateur/Le_codage_des_nombres#Les_nombres_flottants_logarithmiques|Le codage des nombres, dans la section sur les flottants logarithmiques]]. Pour résumer rapidement, ce sont des flottants qui codent uniquement un bit de signe et un exposant, mais sans la mantisse (qui vaut implicitement 1). L'exposant stocké n'est autre que le logarithme en base 2 du nombre codé, d'où le nom donné à ces flottants. Au passage, l'exposant est stocké dans une représentation à virgule fixe. L'utilité de cette représentation est de simplifier fortement les multiplications et divisions, quitte à perdre en performance sur les additions/soustractions.
Pour commencer, il faut savoir que le logarithme d'un produit est égal à la somme des logarithmes. Dans ces conditions, une multiplication entre deux flottants logarithmiques se transforme en une simple addition d'exposants.
: <math>\log (A \times B) = \log A + \log B</math>
Le même raisonnement peut être tenu pour la division, sauf que l'addition est remplacée par une soustraction :
: <math>\log \frac{A}{B} = \log A - \log B</math>
Pour l'addition et la soustraction, le calcul se fait avec cette formule :
: <math>\log_2(x+y) = \log_2(x) + F\left[ \log_2(y) - \log_2(x) \right]</math>, avec F une fonction appelée le logarithme gaussien.
Pour la soustraction, on a la même chose, sauf que la fonction change, ce qui donne :
: <math>\log_2(x - y) = \log_2(x) + G\left[ \log_2(y) - \log_2(x) \right]</math>, avec G une fonction différente de F.
Le calcul demande donc de soustraire les deux opérandes, de calculer la fonction F ou G, puis d'additionner la première opérande. Le toute demande donc un soustracteur, un additionneur et une mémoire de précalcul pour la fonction F/G. Il est possible de mutualiser les additionneurs pour la multiplication et l'addition, en rajoutant quelques multiplexeurs.
[[File:Unité de calcul logarithmique.PNG|centre|vignette|upright=2|Unité de calcul logarithmique]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits de calcul logique et bit à bit
| prevText=Les circuits de calcul logique et bit à bit
| next=Les circuits de calcul trigonométriques
| nextText=Les circuits de calcul trigonométriques
}}
</noinclude>
oxkhtwqfptcngjbi16z0zg1s2kxclkt
775302
775301
2026-10-14T19:03:33Z
Mewtow
31375
/* La comparaison de deux flottants */
775302
wikitext
text/x-wiki
Après avoir vu les circuits de calcul pour les nombres entiers, il est temps de voir les circuits de calculs pour des nombres flottants. Nous allons nous concentrer sur les nombres flottants au format IEEE754, avant de faire un aparté sur les flottants logarithmiques.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Il est souvent dit qu'un processeur incorpore une unité de calcul spécialisée dans les calculs flottants, appelée la ''Floating Point Unit'', ce qui se traduirait en '''unité de calcul flottante'''. Dans la réalité, les processeurs modernes incorporent plusieurs circuits distincts : un pour multiplier deux flottants, un autre pour additionner deux flottants, et éventuellement un troisième pour la division flottante. Ils sont implémentés avec des circuits séparés, comme le sont les ALU et les circuits multiplieurs/diviseurs.
==Les multiplications/divisions flottantes==
Paradoxalement, les multiplications, divisions et racines carrées sont plus simples que l'addition, avec des nombres flottants. Aussi, nous allons d'abord parler des opérations de multiplications et divisions, avant de poursuivre avec les additions et soustractions, en enfin de terminer avec les procédés de normalisation, arrondis et prénormalisation.
Avant le calcul, il y a une étape de '''prénormalisation''', qui gère le bit implicite des mantisses. Elle détermine si ce bit vaut 0 (flottants dénormaux) ou 1 (les flottants normaux), puis l'ajoute aux mantisses. Pour la multiplication et la division, l'étape de prénormalisation ne fait pas autre chose. Mais pour l'addition et la soustraction, elle est plus complexe, comme on le verra plus tard.
===Les circuits multiplieurs/diviseurs flottants===
Prenons deux nombres flottants de mantisses <math>m_1</math> et <math>m_2</math> et les exposants <math>e_1</math> et <math>e_2</math>. Leur multiplication donne :
: <math>(m_1 \times 2^{e_1}) \times (m_2 \times 2^{e_2})</math>
On regroupe les termes :
: <math>(m_1 \times m_2) \times (2^{e_1} \times 2^{e_2})</math>
On simplifie la puissance :
: <math>(m_1 \times m_2) \times 2^{e_1 + e_2}</math>
En clair, multiplier deux flottants revient à multiplier les mantisses et additionner les exposants, ce qui demande un additionneur-soustracteur et un multiplieur. Il faut cependant penser à plusieurs choses pas forcément évidentes.
* Premièrement, il faut ajouter les bits implicites aux mantisses avant de les multiplier, ce qui est le rôle de l'étape de pré-normalisation.
* Deuxièmement, il faut se rappeler que les exposants sont encodés en représentation par excès, ce qui fait qu'il faut utiliser un additionneur-soustracteur en représentation par excès.
* Troisièmement, il faut calculer le bit de signe du résultat à partir de ceux des opérandes.
* Enfin, il ne faut pas oublier de rajouter les étapes de normalisation et d'arrondis, dont on parlera plus bas.
[[File:Multiplieur flottant avec normalisation.PNG|centre|vignette|upright=2|Multiplieur flottant avec normalisation]]
La division fonctionne sur le même principe que la multiplication, si ce n'est que les exposants sont soustraits et que les mantisses sont divisées. Pour le démontrer, prenons deux flottants <math>m_1 \times 2^{e_1}</math> et <math>m_2 \times 2^{e_2}</math> et divisons le premier par le second. On a alors :
: <math>\frac{m1 \times 2^{e_1}}{m2 \times 2^{e_2}}</math>
On applique les règles sur les fractions :
: <math>\frac{m_1}{m_2} \times \frac{2^{e_1}}{2^{e_2}}</math>
On simplifie la puissance de 2 :
: <math>\frac{m_1}{m_2} \times 2^{e_1-e_2}</math>
===La multiplication entière réalisée par l'unité de calcul flottante===
Vous remarquerez qu'un multiplieur flottant contient un multiplieur entier pour multiplier les mantisses. Sachez qu'il est possible de n'utiliser qu'un seul multiplieur entier pour faire à la fois les multiplications entières et flottantes. Cette optimisation a été utilisée sur plusieurs processeurs commerciaux. Par exemple, les processeurs Atom d'Intel utilisaient cette optimisation : les multiplications entières étaient réalisées dans un multiplieur entier partagé avec l'unité de calcul flottante. Il en est de même sur les processeurs Athlon d'AMD, sortis dans les années 2000.
Mais cela ne fonctionnait que sur les processeurs 32 bits, pas sur les 64 bits. Pour comprendre pourquoi, il faut savoir que les processeurs modernes gèrent des nombres flottants simple et double précision, à savoir codés sur 32 et 64 bits. Il y a un seul multiplieur, capable de gérer des flottants double précision, qui peut le plus peut le moins. Avec des flottants double précision de 64 bits, les mantisses font 52 bits, ce qui fait un multiplieur de 53 bits. C'est suffisant pour un processeur 32 bits, pas assez sur un processeur 64 bits.
Ajoutons cependant une petite nuance. Sur les anciens processeurs x86 des PC, les flottants faisaient 80 bits, avec une mantisse de 64 bits, ce qui est assez à la fois pour les processeurs 32 et 64 bits. Malheureusement, les processeurs 64 bits avaient un budget en transistor suffisant pour ne pas appliquer cette optimisation. Pour des raisons diverses, il était préférable d'avoir un multiplieur entier séparé du multiplieur flottant.
==L'addition et la soustraction flottante==
La somme de deux flottants se calcule simplement si les exposants des deux opérandes sont égaux : il suffit alors d'additionner les mantisses. Mais que faire si les deux exposants sont différents ? L'astuce est de mettre les deux flottants au même exposant sans en changer leur valeur, de les mettre à l'échelle. L'exposant choisi étant souvent le plus grand exposant des deux flottants. Une fois mises à l'échelle, les deux opérandes sont additionnées, et le résultat est normalisé pour donner un flottant.
Suivant les signes, il faudra additionner ou soustraire les opérandes : additionner une opérande positive avec une négative demande en réalité de faire une soustraction, de même que soustraire une opérande négative demande en réalité de l'additionner. Il faut donc ajouter, avant l'additionneur, un circuit qui détermine s'il faut faire une addition ou une soustraction, en fonction du bit de signe des opérandes, et de s'il faut faire une addition ou une soustraction (opcode de l'opération voulue).
[[File:Crcuit d'addition et de soustraction flottante.jpg|centre|vignette|upright=2|Circuit d'addition et de soustraction flottante.]]
===Le circuit de pré-normalisation===
La mise des deux opérandes au même exposant s'appelle la '''pré-normalisation'''. L'exposant final est choisit parmi les deux opérandes : on prend le plus grand exposant parmi des deux. L'opérande avec le plus grand exposant reste inchangée, elle est conservée telle quelle. Par contre, il faut pré-normaliser l'autre opérande, celui avec le plus petit exposant. Et pour cela, rien de plus simple : il suffit de décaler la mantisse vers la droite, d'un nombre de rangs égal à la différence entre les deux exposants.
Pour faire ce décalage, on utilise un décaleur et un circuit qui échange les deux opérandes. Le circuit d'échange a pour but d'envoyer le plus petit exposant dans le décaleur et est composé de quelques multiplexeurs. Il est piloté par un comparateur qui détermine quel est le nombre avec le plus petit exposant. Nous verrons comment fabriquer un tel comparateur dans le chapitre suivant sur les comparateurs.
[[File:Circuit de mise au même exposant.jpg|centre|vignette|upright=2|Circuit de mise au même exposant.]]
Précisons que le comparateur et le soustracteur peuvent être fusionnés, car un comparateur est en réalité un soustracteur amélioré. Une manière alternative est la suivante. En premier lieu, on soustrait les exposants pour déterminer de combien décaler la mantisse. Le résultat de la soustraction est ensuite envoyé à un circuit qui vérifie si le résultat est positif ou négatif, en vérifiant le bit de poids fort du résultat. Si le résultat est positif, la première opérande est plus grande que la seconde, c'est la seconde opérande qu'il faut pré-normaliser. Si le résultat est négatif, c'est la première opérande qu'il faut prénormaliser.
[[File:Circuit de prénormalisation d'un additionneur flottant.jpg|centre|vignette|upright=2|Circuit de prénormalisation d'un additionneur flottant]]
==La normalisation et les arrondis==
Calculer sur des nombres flottants peut sembler trivial, mais les mathématiques ne sont pas vraiment d'accord avec cela. En effet, le résultat d'un calcul avec des flottants n'est pas forcément un flottant valide. Il doit subir quelques transformations pour être un nombre flottant : il doit souvent être arrondi, et doit auissi passer par d'autres étapes dites de normalisation.
[[File:Normalisation in circuit.png|vignette|upright=1|Normalisation in circuit]]
Elles corrigent le résultat du calcul pour qu'il rentre dans un nombre flottant. Par exemple, si on multiplie deux flottants de 32 bits, l'exposant et la mantisse du résultat sont calculés séparément et les concaténer ne donne pas forcément un nombre flottant 32 bits. Diverses techniques de normalisation et d'arrondis permettent de corriger l'exposant et la mantisse pour donner un flottant 32 bit correct. Et elles auront leur section dédiée.
La normalisation et les arrondis sont gérés différemment suivant le format de flottant utilisé. Les flottants les plus courants suivent la norme IEEE754, où normalisation et arrondis sont standardisés. Mais d'autres formats de flottants exotiques peuvent suivre des règles différentes.
===La normalisation===
La '''normalisation''' gère le bit implicite. Le résultat en sortie d'un circuit de calcul n'a pas forcément son bit implicite à 1. Prenons l'exemple suivant, où on soustrait deux flottants qui ont des mantisses codées sur 8 bits - le format de flottant n'est donc par standard. On soustrait les deux mantisses suivantes, le chiffre entre parenthèse est le bit implicite : (1) 1100 1100 - (1) 1000 1000 = (0) 0100 0100.
Le résultat a un bit implicite à 0, ce qui donne un résultat dénormal. Mais il est parfois possible de convertir ce résultat en un flottant normal, à condition de corriger l'exposant. L'idée est, pour le cas précédent, de décaler la mantisse de deux rangs : (0) 0100 0100 devient (1) 0001 00''00''. Mais décaler la mantisse déforme le résultat : le résultat décalé de deux rangs vers la gauche multiplie le résultat par 4. Mais on peut compenser exactement le tout en corrigeant l'exposant, afin de diviser le résultat final par 4 : il suffit de soustraire deux à l'exposant !
Le cas général est assez similaire, sauf que l'on doit décaler la mantisse par un nombre de rang adéquat, pas forcément 2, et soustraire ce nombre de rangs à l'exposant. Pour savoir de combien de rangs il faut décaler, il faut compter le nombre de zéros situés de poids fort, avec un circuit spécialisé qu'on a vu il y a quelques chapitres, le circuit de CLZ (''Count Leading Zero''). Ce circuit permet aussi de détecter si la mantisse vaut zéro.
[[File:Circuit de prénormalisation.jpg|centre|vignette|upright=2|Circuit de normalisation.]]
===Les arrondis===
Une fois ce résultat calculé, il faut faire un arrondi du résultat avec un circuit d''''arrondi'''. L'arrondi se base sur les bits de poids faible situés juste à gauche et à droite de la virgule., ce qui demande d'analyser une dizaine de bits tout au plus. Une fois les bits de poids faible à gauche de la virgule sont remplacé, les bits à droite sont éliminés.
L'arrondi peut être réalisé par un circuit combinatoire, mais le faible nombre de bits d'entrée rend possible d'utiliser ce qui s'appelle une '''mémoire de précalcul'''. Avec cette technique, le circuit combinatoire est remplacé par une ROM équivalente. Les entrées du circuit combinatoire deviennent l'entrée d'adresse de la ROM, les sorties du circuit deviennent la sortie de donnée de la ROM. Pour une entrée identique, les deux donneront les mêmes sorties.
[[File:Circuit d'arrondi flottant basé sur une ROM.png|centre|vignette|upright=1.5|Circuit d'arrondi flottant basé sur une ROM.]]
Malheureusement, il arrive que ces arrondis décalent la position du bit implicite d'un rang, ce qui se résout avec un décalage si cela arrive. Le circuit de normalisation contient donc de quoi détecter ces débordements et un décaleur. Bien évidemment, l'exposant doit alors lui aussi être corrigé en cas de décalage de la mantisse.
[[File:Circuit de postnormalisation.jpg|centre|vignette|upright=2|Circuit de postnormalisation.]]
==La comparaison de deux flottants==
Nous venons de voir deux opérations arithmétiques : l'addition et la multiplication. Mais nous n'avons pas vu une opération moins intuitive ; les comparaisons ! Il est en effet possible de comparer deux nombres flottants, pour savoir si ils sont égaux, différents, si le premier est supérieur au second, inférieur au second, etc. La comparaison de deux flottants ne ressemble pas beaucoup à celle de deux nombres entiers. Le fait que les flottants sont composés d'un bit de signe, d'un exposant et d'une mantisse, avec un bit implicite, rend les comparaisons plus compliquées.
Une solution consiste à compare les bits de signe, les exposants et les mantisses séparément. Les exposants sont encodés avec des nombres entiers (en représentation par excès), idem pour les mantisses. Je viens de dire que les exposants et mantisses sont comparées séparément. Il est possible de les comparer en série : son test d'abord les exposants, et on ne compare les mantisses que s'ils sont égaux. Une autre technique compare les exposants et mantisses en parallèle, et le résultat pour les exposants prend le pas sur le reste.
La seule difficulté est de déterminer le bit implicite des mantisses, du moins semble-t-il. Sauf qu'il faut aussi prendre en compte les cas particuliers : les dénormaux, les NaNs, les infinis. Le comparateur flottant doit donc tester si les opérandes, pour vérifier si ce sont des NaNs, des infinis, un zéro, voire un dénormal. Dans ce cas, le résultat de la comparaison est déterminé par un circuit séparé, qui traite ces cas particuliers.
Une solution alternative compare deux nombres flottants en utilisant les comparaisons pour nombres entiers. L'encodage des flottants fait que l'on peut comparer deux flottants comme s'ils s'agissait d'entiers sans problèmes. Concrètement, un flottant est encodée avec une suite de bits qui peut être interprétée comme un nombre entier. Si on fait ca pour deux flottants, on obtient deux "entiers", qu'on pourra comparer avec un comparateur entier. Et le résultat sera bon, sauf pour certaines opérandes.
Cela vient du fait que deux flottants consécutifs auront un "encodage entier" consécutifs, chose qui est garantie par la norme IEEE754. Si on compare deux flottants, le plus grand des deux est celui qui a l'exposant le plus grand, ou la mantisse la plus grande si les deux exposants sont égaux. L'exposant étant dans les bits de poids fort, on comprend que l'exposant passera en priorité dans la comparaison, et que les mantisses seront comparées si les deux exposants sont égaux. Il ne faut cependant pas oublier la présence du bit de signe, qui impose d'utiliser une comparaison signée, un comparateur en signe-magnitude
==Les flottants logarithmiques==
Maintenant, nous allons fabriquer une FPU pour les flottants logarithmiques. Nous avions vu les flottants logarithmiques dans le chapitre [[Fonctionnement d'un ordinateur/Le_codage_des_nombres#Les_nombres_flottants_logarithmiques|Le codage des nombres, dans la section sur les flottants logarithmiques]]. Pour résumer rapidement, ce sont des flottants qui codent uniquement un bit de signe et un exposant, mais sans la mantisse (qui vaut implicitement 1). L'exposant stocké n'est autre que le logarithme en base 2 du nombre codé, d'où le nom donné à ces flottants. Au passage, l'exposant est stocké dans une représentation à virgule fixe. L'utilité de cette représentation est de simplifier fortement les multiplications et divisions, quitte à perdre en performance sur les additions/soustractions.
Pour commencer, il faut savoir que le logarithme d'un produit est égal à la somme des logarithmes. Dans ces conditions, une multiplication entre deux flottants logarithmiques se transforme en une simple addition d'exposants.
: <math>\log (A \times B) = \log A + \log B</math>
Le même raisonnement peut être tenu pour la division, sauf que l'addition est remplacée par une soustraction :
: <math>\log \frac{A}{B} = \log A - \log B</math>
Pour l'addition et la soustraction, le calcul se fait avec cette formule :
: <math>\log_2(x+y) = \log_2(x) + F\left[ \log_2(y) - \log_2(x) \right]</math>, avec F une fonction appelée le logarithme gaussien.
Pour la soustraction, on a la même chose, sauf que la fonction change, ce qui donne :
: <math>\log_2(x - y) = \log_2(x) + G\left[ \log_2(y) - \log_2(x) \right]</math>, avec G une fonction différente de F.
Le calcul demande donc de soustraire les deux opérandes, de calculer la fonction F ou G, puis d'additionner la première opérande. Le toute demande donc un soustracteur, un additionneur et une mémoire de précalcul pour la fonction F/G. Il est possible de mutualiser les additionneurs pour la multiplication et l'addition, en rajoutant quelques multiplexeurs.
[[File:Unité de calcul logarithmique.PNG|centre|vignette|upright=2|Unité de calcul logarithmique]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits de calcul logique et bit à bit
| prevText=Les circuits de calcul logique et bit à bit
| next=Les circuits de calcul trigonométriques
| nextText=Les circuits de calcul trigonométriques
}}
</noinclude>
d2eu9vxxent2fxob8ds2iuwwwyryyv1
775303
775302
2026-10-14T19:08:31Z
Mewtow
31375
/* La comparaison de deux flottants */
775303
wikitext
text/x-wiki
Après avoir vu les circuits de calcul pour les nombres entiers, il est temps de voir les circuits de calculs pour des nombres flottants. Nous allons nous concentrer sur les nombres flottants au format IEEE754, avant de faire un aparté sur les flottants logarithmiques.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Il est souvent dit qu'un processeur incorpore une unité de calcul spécialisée dans les calculs flottants, appelée la ''Floating Point Unit'', ce qui se traduirait en '''unité de calcul flottante'''. Dans la réalité, les processeurs modernes incorporent plusieurs circuits distincts : un pour multiplier deux flottants, un autre pour additionner deux flottants, et éventuellement un troisième pour la division flottante. Ils sont implémentés avec des circuits séparés, comme le sont les ALU et les circuits multiplieurs/diviseurs.
==Les multiplications/divisions flottantes==
Paradoxalement, les multiplications, divisions et racines carrées sont plus simples que l'addition, avec des nombres flottants. Aussi, nous allons d'abord parler des opérations de multiplications et divisions, avant de poursuivre avec les additions et soustractions, en enfin de terminer avec les procédés de normalisation, arrondis et prénormalisation.
Avant le calcul, il y a une étape de '''prénormalisation''', qui gère le bit implicite des mantisses. Elle détermine si ce bit vaut 0 (flottants dénormaux) ou 1 (les flottants normaux), puis l'ajoute aux mantisses. Pour la multiplication et la division, l'étape de prénormalisation ne fait pas autre chose. Mais pour l'addition et la soustraction, elle est plus complexe, comme on le verra plus tard.
===Les circuits multiplieurs/diviseurs flottants===
Prenons deux nombres flottants de mantisses <math>m_1</math> et <math>m_2</math> et les exposants <math>e_1</math> et <math>e_2</math>. Leur multiplication donne :
: <math>(m_1 \times 2^{e_1}) \times (m_2 \times 2^{e_2})</math>
On regroupe les termes :
: <math>(m_1 \times m_2) \times (2^{e_1} \times 2^{e_2})</math>
On simplifie la puissance :
: <math>(m_1 \times m_2) \times 2^{e_1 + e_2}</math>
En clair, multiplier deux flottants revient à multiplier les mantisses et additionner les exposants, ce qui demande un additionneur-soustracteur et un multiplieur. Il faut cependant penser à plusieurs choses pas forcément évidentes.
* Premièrement, il faut ajouter les bits implicites aux mantisses avant de les multiplier, ce qui est le rôle de l'étape de pré-normalisation.
* Deuxièmement, il faut se rappeler que les exposants sont encodés en représentation par excès, ce qui fait qu'il faut utiliser un additionneur-soustracteur en représentation par excès.
* Troisièmement, il faut calculer le bit de signe du résultat à partir de ceux des opérandes.
* Enfin, il ne faut pas oublier de rajouter les étapes de normalisation et d'arrondis, dont on parlera plus bas.
[[File:Multiplieur flottant avec normalisation.PNG|centre|vignette|upright=2|Multiplieur flottant avec normalisation]]
La division fonctionne sur le même principe que la multiplication, si ce n'est que les exposants sont soustraits et que les mantisses sont divisées. Pour le démontrer, prenons deux flottants <math>m_1 \times 2^{e_1}</math> et <math>m_2 \times 2^{e_2}</math> et divisons le premier par le second. On a alors :
: <math>\frac{m1 \times 2^{e_1}}{m2 \times 2^{e_2}}</math>
On applique les règles sur les fractions :
: <math>\frac{m_1}{m_2} \times \frac{2^{e_1}}{2^{e_2}}</math>
On simplifie la puissance de 2 :
: <math>\frac{m_1}{m_2} \times 2^{e_1-e_2}</math>
===La multiplication entière réalisée par l'unité de calcul flottante===
Vous remarquerez qu'un multiplieur flottant contient un multiplieur entier pour multiplier les mantisses. Sachez qu'il est possible de n'utiliser qu'un seul multiplieur entier pour faire à la fois les multiplications entières et flottantes. Cette optimisation a été utilisée sur plusieurs processeurs commerciaux. Par exemple, les processeurs Atom d'Intel utilisaient cette optimisation : les multiplications entières étaient réalisées dans un multiplieur entier partagé avec l'unité de calcul flottante. Il en est de même sur les processeurs Athlon d'AMD, sortis dans les années 2000.
Mais cela ne fonctionnait que sur les processeurs 32 bits, pas sur les 64 bits. Pour comprendre pourquoi, il faut savoir que les processeurs modernes gèrent des nombres flottants simple et double précision, à savoir codés sur 32 et 64 bits. Il y a un seul multiplieur, capable de gérer des flottants double précision, qui peut le plus peut le moins. Avec des flottants double précision de 64 bits, les mantisses font 52 bits, ce qui fait un multiplieur de 53 bits. C'est suffisant pour un processeur 32 bits, pas assez sur un processeur 64 bits.
Ajoutons cependant une petite nuance. Sur les anciens processeurs x86 des PC, les flottants faisaient 80 bits, avec une mantisse de 64 bits, ce qui est assez à la fois pour les processeurs 32 et 64 bits. Malheureusement, les processeurs 64 bits avaient un budget en transistor suffisant pour ne pas appliquer cette optimisation. Pour des raisons diverses, il était préférable d'avoir un multiplieur entier séparé du multiplieur flottant.
==L'addition et la soustraction flottante==
La somme de deux flottants se calcule simplement si les exposants des deux opérandes sont égaux : il suffit alors d'additionner les mantisses. Mais que faire si les deux exposants sont différents ? L'astuce est de mettre les deux flottants au même exposant sans en changer leur valeur, de les mettre à l'échelle. L'exposant choisi étant souvent le plus grand exposant des deux flottants. Une fois mises à l'échelle, les deux opérandes sont additionnées, et le résultat est normalisé pour donner un flottant.
Suivant les signes, il faudra additionner ou soustraire les opérandes : additionner une opérande positive avec une négative demande en réalité de faire une soustraction, de même que soustraire une opérande négative demande en réalité de l'additionner. Il faut donc ajouter, avant l'additionneur, un circuit qui détermine s'il faut faire une addition ou une soustraction, en fonction du bit de signe des opérandes, et de s'il faut faire une addition ou une soustraction (opcode de l'opération voulue).
[[File:Crcuit d'addition et de soustraction flottante.jpg|centre|vignette|upright=2|Circuit d'addition et de soustraction flottante.]]
===Le circuit de pré-normalisation===
La mise des deux opérandes au même exposant s'appelle la '''pré-normalisation'''. L'exposant final est choisit parmi les deux opérandes : on prend le plus grand exposant parmi des deux. L'opérande avec le plus grand exposant reste inchangée, elle est conservée telle quelle. Par contre, il faut pré-normaliser l'autre opérande, celui avec le plus petit exposant. Et pour cela, rien de plus simple : il suffit de décaler la mantisse vers la droite, d'un nombre de rangs égal à la différence entre les deux exposants.
Pour faire ce décalage, on utilise un décaleur et un circuit qui échange les deux opérandes. Le circuit d'échange a pour but d'envoyer le plus petit exposant dans le décaleur et est composé de quelques multiplexeurs. Il est piloté par un comparateur qui détermine quel est le nombre avec le plus petit exposant. Nous verrons comment fabriquer un tel comparateur dans le chapitre suivant sur les comparateurs.
[[File:Circuit de mise au même exposant.jpg|centre|vignette|upright=2|Circuit de mise au même exposant.]]
Précisons que le comparateur et le soustracteur peuvent être fusionnés, car un comparateur est en réalité un soustracteur amélioré. Une manière alternative est la suivante. En premier lieu, on soustrait les exposants pour déterminer de combien décaler la mantisse. Le résultat de la soustraction est ensuite envoyé à un circuit qui vérifie si le résultat est positif ou négatif, en vérifiant le bit de poids fort du résultat. Si le résultat est positif, la première opérande est plus grande que la seconde, c'est la seconde opérande qu'il faut pré-normaliser. Si le résultat est négatif, c'est la première opérande qu'il faut prénormaliser.
[[File:Circuit de prénormalisation d'un additionneur flottant.jpg|centre|vignette|upright=2|Circuit de prénormalisation d'un additionneur flottant]]
==La normalisation et les arrondis==
Calculer sur des nombres flottants peut sembler trivial, mais les mathématiques ne sont pas vraiment d'accord avec cela. En effet, le résultat d'un calcul avec des flottants n'est pas forcément un flottant valide. Il doit subir quelques transformations pour être un nombre flottant : il doit souvent être arrondi, et doit auissi passer par d'autres étapes dites de normalisation.
[[File:Normalisation in circuit.png|vignette|upright=1|Normalisation in circuit]]
Elles corrigent le résultat du calcul pour qu'il rentre dans un nombre flottant. Par exemple, si on multiplie deux flottants de 32 bits, l'exposant et la mantisse du résultat sont calculés séparément et les concaténer ne donne pas forcément un nombre flottant 32 bits. Diverses techniques de normalisation et d'arrondis permettent de corriger l'exposant et la mantisse pour donner un flottant 32 bit correct. Et elles auront leur section dédiée.
La normalisation et les arrondis sont gérés différemment suivant le format de flottant utilisé. Les flottants les plus courants suivent la norme IEEE754, où normalisation et arrondis sont standardisés. Mais d'autres formats de flottants exotiques peuvent suivre des règles différentes.
===La normalisation===
La '''normalisation''' gère le bit implicite. Le résultat en sortie d'un circuit de calcul n'a pas forcément son bit implicite à 1. Prenons l'exemple suivant, où on soustrait deux flottants qui ont des mantisses codées sur 8 bits - le format de flottant n'est donc par standard. On soustrait les deux mantisses suivantes, le chiffre entre parenthèse est le bit implicite : (1) 1100 1100 - (1) 1000 1000 = (0) 0100 0100.
Le résultat a un bit implicite à 0, ce qui donne un résultat dénormal. Mais il est parfois possible de convertir ce résultat en un flottant normal, à condition de corriger l'exposant. L'idée est, pour le cas précédent, de décaler la mantisse de deux rangs : (0) 0100 0100 devient (1) 0001 00''00''. Mais décaler la mantisse déforme le résultat : le résultat décalé de deux rangs vers la gauche multiplie le résultat par 4. Mais on peut compenser exactement le tout en corrigeant l'exposant, afin de diviser le résultat final par 4 : il suffit de soustraire deux à l'exposant !
Le cas général est assez similaire, sauf que l'on doit décaler la mantisse par un nombre de rang adéquat, pas forcément 2, et soustraire ce nombre de rangs à l'exposant. Pour savoir de combien de rangs il faut décaler, il faut compter le nombre de zéros situés de poids fort, avec un circuit spécialisé qu'on a vu il y a quelques chapitres, le circuit de CLZ (''Count Leading Zero''). Ce circuit permet aussi de détecter si la mantisse vaut zéro.
[[File:Circuit de prénormalisation.jpg|centre|vignette|upright=2|Circuit de normalisation.]]
===Les arrondis===
Une fois ce résultat calculé, il faut faire un arrondi du résultat avec un circuit d''''arrondi'''. L'arrondi se base sur les bits de poids faible situés juste à gauche et à droite de la virgule., ce qui demande d'analyser une dizaine de bits tout au plus. Une fois les bits de poids faible à gauche de la virgule sont remplacé, les bits à droite sont éliminés.
L'arrondi peut être réalisé par un circuit combinatoire, mais le faible nombre de bits d'entrée rend possible d'utiliser ce qui s'appelle une '''mémoire de précalcul'''. Avec cette technique, le circuit combinatoire est remplacé par une ROM équivalente. Les entrées du circuit combinatoire deviennent l'entrée d'adresse de la ROM, les sorties du circuit deviennent la sortie de donnée de la ROM. Pour une entrée identique, les deux donneront les mêmes sorties.
[[File:Circuit d'arrondi flottant basé sur une ROM.png|centre|vignette|upright=1.5|Circuit d'arrondi flottant basé sur une ROM.]]
Malheureusement, il arrive que ces arrondis décalent la position du bit implicite d'un rang, ce qui se résout avec un décalage si cela arrive. Le circuit de normalisation contient donc de quoi détecter ces débordements et un décaleur. Bien évidemment, l'exposant doit alors lui aussi être corrigé en cas de décalage de la mantisse.
[[File:Circuit de postnormalisation.jpg|centre|vignette|upright=2|Circuit de postnormalisation.]]
==La comparaison de deux flottants==
Nous venons de voir deux opérations arithmétiques : l'addition et la multiplication. Mais nous n'avons pas vu une opération moins intuitive ; les comparaisons ! Il est en effet possible de comparer deux nombres flottants, pour savoir si ils sont égaux, différents, si le premier est supérieur au second, inférieur au second, etc. La comparaison de deux flottants ne ressemble pas beaucoup à celle de deux nombres entiers. Le fait que les flottants sont composés d'un bit de signe, d'un exposant et d'une mantisse, avec un bit implicite, rend les comparaisons plus compliquées.
La seule difficulté est de déterminer le bit implicite des mantisses, du moins semble-t-il. Sauf qu'il faut aussi prendre en compte les '''cas spéciaux''' : les dénormaux, les NaNs, les infinis. Le comparateur flottant doit donc tester si les opérandes, pour vérifier si ce sont des NaNs, des infinis, un zéro, voire un dénormal. Dans ce cas, le résultat de la comparaison est déterminé par un circuit séparé, qui traite ces cas particuliers.
===La comparaison flottante, sans tenir compte des cas spéciaux===
Une solution consiste à compare les bits de signe, les exposants et les mantisses séparément. Les exposants sont encodés avec des nombres entiers (en représentation par excès), idem pour les mantisses. Je viens de dire que les exposants et mantisses sont comparées séparément. Il est possible de les comparer en série : son test d'abord les exposants, et on ne compare les mantisses que s'ils sont égaux. Une autre technique compare les exposants et mantisses en parallèle, et le résultat pour les exposants prend le pas sur le reste.
Une solution alternative compare deux nombres flottants en utilisant les comparaisons pour nombres entiers. L'encodage des flottants fait que l'on peut comparer deux flottants comme s'ils s'agissait d'entiers sans problèmes. Concrètement, un flottant est encodée avec une suite de bits qui peut être interprétée comme un nombre entier. Si on fait ca pour deux flottants, on obtient deux "entiers", qu'on pourra comparer avec un comparateur entier. Et le résultat sera bon, sauf pour certaines opérandes.
Cela vient du fait que deux flottants consécutifs auront un "encodage entier" consécutifs, chose qui est garantie par la norme IEEE754. Si on compare deux flottants, le plus grand des deux est celui qui a l'exposant le plus grand, ou la mantisse la plus grande si les deux exposants sont égaux. L'exposant étant dans les bits de poids fort, on comprend que l'exposant passera en priorité dans la comparaison, et que les mantisses seront comparées si les deux exposants sont égaux. Il ne faut cependant pas oublier la présence du bit de signe, qui impose d'utiliser une comparaison signée, un comparateur en signe-magnitude
===La comparaison flottante pour les cas spéciaux===
Les cas spéciaux sont les NaNs, les zéros, les infinis et les dénormaux. Pour les zéros, la subtilité est qu'il y a deux zéros : +0 et -0. Et les deux doivent être considérés comme égaux. Or, si on compare les deux flottants sans en tenir compte, on se retrouverait avec ''+0 > -0''. Il faut donc ajouter un circuit qui détecte ce cas spécial. Il compare les deux flottants, sans leur bit de sign, pour vérifier si leurs exposants et mantisses valent toutes deux zéros. Si c'est le cas, le bit de signe n'est pas pris en compte.
==Les flottants logarithmiques==
Maintenant, nous allons fabriquer une FPU pour les flottants logarithmiques. Nous avions vu les flottants logarithmiques dans le chapitre [[Fonctionnement d'un ordinateur/Le_codage_des_nombres#Les_nombres_flottants_logarithmiques|Le codage des nombres, dans la section sur les flottants logarithmiques]]. Pour résumer rapidement, ce sont des flottants qui codent uniquement un bit de signe et un exposant, mais sans la mantisse (qui vaut implicitement 1). L'exposant stocké n'est autre que le logarithme en base 2 du nombre codé, d'où le nom donné à ces flottants. Au passage, l'exposant est stocké dans une représentation à virgule fixe. L'utilité de cette représentation est de simplifier fortement les multiplications et divisions, quitte à perdre en performance sur les additions/soustractions.
Pour commencer, il faut savoir que le logarithme d'un produit est égal à la somme des logarithmes. Dans ces conditions, une multiplication entre deux flottants logarithmiques se transforme en une simple addition d'exposants.
: <math>\log (A \times B) = \log A + \log B</math>
Le même raisonnement peut être tenu pour la division, sauf que l'addition est remplacée par une soustraction :
: <math>\log \frac{A}{B} = \log A - \log B</math>
Pour l'addition et la soustraction, le calcul se fait avec cette formule :
: <math>\log_2(x+y) = \log_2(x) + F\left[ \log_2(y) - \log_2(x) \right]</math>, avec F une fonction appelée le logarithme gaussien.
Pour la soustraction, on a la même chose, sauf que la fonction change, ce qui donne :
: <math>\log_2(x - y) = \log_2(x) + G\left[ \log_2(y) - \log_2(x) \right]</math>, avec G une fonction différente de F.
Le calcul demande donc de soustraire les deux opérandes, de calculer la fonction F ou G, puis d'additionner la première opérande. Le toute demande donc un soustracteur, un additionneur et une mémoire de précalcul pour la fonction F/G. Il est possible de mutualiser les additionneurs pour la multiplication et l'addition, en rajoutant quelques multiplexeurs.
[[File:Unité de calcul logarithmique.PNG|centre|vignette|upright=2|Unité de calcul logarithmique]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits de calcul logique et bit à bit
| prevText=Les circuits de calcul logique et bit à bit
| next=Les circuits de calcul trigonométriques
| nextText=Les circuits de calcul trigonométriques
}}
</noinclude>
n1o63buiwy9ma18uza5jpfs7l9rnc57
775310
775303
2026-10-14T19:22:43Z
Mewtow
31375
/* La comparaison flottante pour les cas spéciaux */
775310
wikitext
text/x-wiki
Après avoir vu les circuits de calcul pour les nombres entiers, il est temps de voir les circuits de calculs pour des nombres flottants. Nous allons nous concentrer sur les nombres flottants au format IEEE754, avant de faire un aparté sur les flottants logarithmiques.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Il est souvent dit qu'un processeur incorpore une unité de calcul spécialisée dans les calculs flottants, appelée la ''Floating Point Unit'', ce qui se traduirait en '''unité de calcul flottante'''. Dans la réalité, les processeurs modernes incorporent plusieurs circuits distincts : un pour multiplier deux flottants, un autre pour additionner deux flottants, et éventuellement un troisième pour la division flottante. Ils sont implémentés avec des circuits séparés, comme le sont les ALU et les circuits multiplieurs/diviseurs.
==Les multiplications/divisions flottantes==
Paradoxalement, les multiplications, divisions et racines carrées sont plus simples que l'addition, avec des nombres flottants. Aussi, nous allons d'abord parler des opérations de multiplications et divisions, avant de poursuivre avec les additions et soustractions, en enfin de terminer avec les procédés de normalisation, arrondis et prénormalisation.
Avant le calcul, il y a une étape de '''prénormalisation''', qui gère le bit implicite des mantisses. Elle détermine si ce bit vaut 0 (flottants dénormaux) ou 1 (les flottants normaux), puis l'ajoute aux mantisses. Pour la multiplication et la division, l'étape de prénormalisation ne fait pas autre chose. Mais pour l'addition et la soustraction, elle est plus complexe, comme on le verra plus tard.
===Les circuits multiplieurs/diviseurs flottants===
Prenons deux nombres flottants de mantisses <math>m_1</math> et <math>m_2</math> et les exposants <math>e_1</math> et <math>e_2</math>. Leur multiplication donne :
: <math>(m_1 \times 2^{e_1}) \times (m_2 \times 2^{e_2})</math>
On regroupe les termes :
: <math>(m_1 \times m_2) \times (2^{e_1} \times 2^{e_2})</math>
On simplifie la puissance :
: <math>(m_1 \times m_2) \times 2^{e_1 + e_2}</math>
En clair, multiplier deux flottants revient à multiplier les mantisses et additionner les exposants, ce qui demande un additionneur-soustracteur et un multiplieur. Il faut cependant penser à plusieurs choses pas forcément évidentes.
* Premièrement, il faut ajouter les bits implicites aux mantisses avant de les multiplier, ce qui est le rôle de l'étape de pré-normalisation.
* Deuxièmement, il faut se rappeler que les exposants sont encodés en représentation par excès, ce qui fait qu'il faut utiliser un additionneur-soustracteur en représentation par excès.
* Troisièmement, il faut calculer le bit de signe du résultat à partir de ceux des opérandes.
* Enfin, il ne faut pas oublier de rajouter les étapes de normalisation et d'arrondis, dont on parlera plus bas.
[[File:Multiplieur flottant avec normalisation.PNG|centre|vignette|upright=2|Multiplieur flottant avec normalisation]]
La division fonctionne sur le même principe que la multiplication, si ce n'est que les exposants sont soustraits et que les mantisses sont divisées. Pour le démontrer, prenons deux flottants <math>m_1 \times 2^{e_1}</math> et <math>m_2 \times 2^{e_2}</math> et divisons le premier par le second. On a alors :
: <math>\frac{m1 \times 2^{e_1}}{m2 \times 2^{e_2}}</math>
On applique les règles sur les fractions :
: <math>\frac{m_1}{m_2} \times \frac{2^{e_1}}{2^{e_2}}</math>
On simplifie la puissance de 2 :
: <math>\frac{m_1}{m_2} \times 2^{e_1-e_2}</math>
===La multiplication entière réalisée par l'unité de calcul flottante===
Vous remarquerez qu'un multiplieur flottant contient un multiplieur entier pour multiplier les mantisses. Sachez qu'il est possible de n'utiliser qu'un seul multiplieur entier pour faire à la fois les multiplications entières et flottantes. Cette optimisation a été utilisée sur plusieurs processeurs commerciaux. Par exemple, les processeurs Atom d'Intel utilisaient cette optimisation : les multiplications entières étaient réalisées dans un multiplieur entier partagé avec l'unité de calcul flottante. Il en est de même sur les processeurs Athlon d'AMD, sortis dans les années 2000.
Mais cela ne fonctionnait que sur les processeurs 32 bits, pas sur les 64 bits. Pour comprendre pourquoi, il faut savoir que les processeurs modernes gèrent des nombres flottants simple et double précision, à savoir codés sur 32 et 64 bits. Il y a un seul multiplieur, capable de gérer des flottants double précision, qui peut le plus peut le moins. Avec des flottants double précision de 64 bits, les mantisses font 52 bits, ce qui fait un multiplieur de 53 bits. C'est suffisant pour un processeur 32 bits, pas assez sur un processeur 64 bits.
Ajoutons cependant une petite nuance. Sur les anciens processeurs x86 des PC, les flottants faisaient 80 bits, avec une mantisse de 64 bits, ce qui est assez à la fois pour les processeurs 32 et 64 bits. Malheureusement, les processeurs 64 bits avaient un budget en transistor suffisant pour ne pas appliquer cette optimisation. Pour des raisons diverses, il était préférable d'avoir un multiplieur entier séparé du multiplieur flottant.
==L'addition et la soustraction flottante==
La somme de deux flottants se calcule simplement si les exposants des deux opérandes sont égaux : il suffit alors d'additionner les mantisses. Mais que faire si les deux exposants sont différents ? L'astuce est de mettre les deux flottants au même exposant sans en changer leur valeur, de les mettre à l'échelle. L'exposant choisi étant souvent le plus grand exposant des deux flottants. Une fois mises à l'échelle, les deux opérandes sont additionnées, et le résultat est normalisé pour donner un flottant.
Suivant les signes, il faudra additionner ou soustraire les opérandes : additionner une opérande positive avec une négative demande en réalité de faire une soustraction, de même que soustraire une opérande négative demande en réalité de l'additionner. Il faut donc ajouter, avant l'additionneur, un circuit qui détermine s'il faut faire une addition ou une soustraction, en fonction du bit de signe des opérandes, et de s'il faut faire une addition ou une soustraction (opcode de l'opération voulue).
[[File:Crcuit d'addition et de soustraction flottante.jpg|centre|vignette|upright=2|Circuit d'addition et de soustraction flottante.]]
===Le circuit de pré-normalisation===
La mise des deux opérandes au même exposant s'appelle la '''pré-normalisation'''. L'exposant final est choisit parmi les deux opérandes : on prend le plus grand exposant parmi des deux. L'opérande avec le plus grand exposant reste inchangée, elle est conservée telle quelle. Par contre, il faut pré-normaliser l'autre opérande, celui avec le plus petit exposant. Et pour cela, rien de plus simple : il suffit de décaler la mantisse vers la droite, d'un nombre de rangs égal à la différence entre les deux exposants.
Pour faire ce décalage, on utilise un décaleur et un circuit qui échange les deux opérandes. Le circuit d'échange a pour but d'envoyer le plus petit exposant dans le décaleur et est composé de quelques multiplexeurs. Il est piloté par un comparateur qui détermine quel est le nombre avec le plus petit exposant. Nous verrons comment fabriquer un tel comparateur dans le chapitre suivant sur les comparateurs.
[[File:Circuit de mise au même exposant.jpg|centre|vignette|upright=2|Circuit de mise au même exposant.]]
Précisons que le comparateur et le soustracteur peuvent être fusionnés, car un comparateur est en réalité un soustracteur amélioré. Une manière alternative est la suivante. En premier lieu, on soustrait les exposants pour déterminer de combien décaler la mantisse. Le résultat de la soustraction est ensuite envoyé à un circuit qui vérifie si le résultat est positif ou négatif, en vérifiant le bit de poids fort du résultat. Si le résultat est positif, la première opérande est plus grande que la seconde, c'est la seconde opérande qu'il faut pré-normaliser. Si le résultat est négatif, c'est la première opérande qu'il faut prénormaliser.
[[File:Circuit de prénormalisation d'un additionneur flottant.jpg|centre|vignette|upright=2|Circuit de prénormalisation d'un additionneur flottant]]
==La normalisation et les arrondis==
Calculer sur des nombres flottants peut sembler trivial, mais les mathématiques ne sont pas vraiment d'accord avec cela. En effet, le résultat d'un calcul avec des flottants n'est pas forcément un flottant valide. Il doit subir quelques transformations pour être un nombre flottant : il doit souvent être arrondi, et doit auissi passer par d'autres étapes dites de normalisation.
[[File:Normalisation in circuit.png|vignette|upright=1|Normalisation in circuit]]
Elles corrigent le résultat du calcul pour qu'il rentre dans un nombre flottant. Par exemple, si on multiplie deux flottants de 32 bits, l'exposant et la mantisse du résultat sont calculés séparément et les concaténer ne donne pas forcément un nombre flottant 32 bits. Diverses techniques de normalisation et d'arrondis permettent de corriger l'exposant et la mantisse pour donner un flottant 32 bit correct. Et elles auront leur section dédiée.
La normalisation et les arrondis sont gérés différemment suivant le format de flottant utilisé. Les flottants les plus courants suivent la norme IEEE754, où normalisation et arrondis sont standardisés. Mais d'autres formats de flottants exotiques peuvent suivre des règles différentes.
===La normalisation===
La '''normalisation''' gère le bit implicite. Le résultat en sortie d'un circuit de calcul n'a pas forcément son bit implicite à 1. Prenons l'exemple suivant, où on soustrait deux flottants qui ont des mantisses codées sur 8 bits - le format de flottant n'est donc par standard. On soustrait les deux mantisses suivantes, le chiffre entre parenthèse est le bit implicite : (1) 1100 1100 - (1) 1000 1000 = (0) 0100 0100.
Le résultat a un bit implicite à 0, ce qui donne un résultat dénormal. Mais il est parfois possible de convertir ce résultat en un flottant normal, à condition de corriger l'exposant. L'idée est, pour le cas précédent, de décaler la mantisse de deux rangs : (0) 0100 0100 devient (1) 0001 00''00''. Mais décaler la mantisse déforme le résultat : le résultat décalé de deux rangs vers la gauche multiplie le résultat par 4. Mais on peut compenser exactement le tout en corrigeant l'exposant, afin de diviser le résultat final par 4 : il suffit de soustraire deux à l'exposant !
Le cas général est assez similaire, sauf que l'on doit décaler la mantisse par un nombre de rang adéquat, pas forcément 2, et soustraire ce nombre de rangs à l'exposant. Pour savoir de combien de rangs il faut décaler, il faut compter le nombre de zéros situés de poids fort, avec un circuit spécialisé qu'on a vu il y a quelques chapitres, le circuit de CLZ (''Count Leading Zero''). Ce circuit permet aussi de détecter si la mantisse vaut zéro.
[[File:Circuit de prénormalisation.jpg|centre|vignette|upright=2|Circuit de normalisation.]]
===Les arrondis===
Une fois ce résultat calculé, il faut faire un arrondi du résultat avec un circuit d''''arrondi'''. L'arrondi se base sur les bits de poids faible situés juste à gauche et à droite de la virgule., ce qui demande d'analyser une dizaine de bits tout au plus. Une fois les bits de poids faible à gauche de la virgule sont remplacé, les bits à droite sont éliminés.
L'arrondi peut être réalisé par un circuit combinatoire, mais le faible nombre de bits d'entrée rend possible d'utiliser ce qui s'appelle une '''mémoire de précalcul'''. Avec cette technique, le circuit combinatoire est remplacé par une ROM équivalente. Les entrées du circuit combinatoire deviennent l'entrée d'adresse de la ROM, les sorties du circuit deviennent la sortie de donnée de la ROM. Pour une entrée identique, les deux donneront les mêmes sorties.
[[File:Circuit d'arrondi flottant basé sur une ROM.png|centre|vignette|upright=1.5|Circuit d'arrondi flottant basé sur une ROM.]]
Malheureusement, il arrive que ces arrondis décalent la position du bit implicite d'un rang, ce qui se résout avec un décalage si cela arrive. Le circuit de normalisation contient donc de quoi détecter ces débordements et un décaleur. Bien évidemment, l'exposant doit alors lui aussi être corrigé en cas de décalage de la mantisse.
[[File:Circuit de postnormalisation.jpg|centre|vignette|upright=2|Circuit de postnormalisation.]]
==La comparaison de deux flottants==
Nous venons de voir deux opérations arithmétiques : l'addition et la multiplication. Mais nous n'avons pas vu une opération moins intuitive ; les comparaisons ! Il est en effet possible de comparer deux nombres flottants, pour savoir si ils sont égaux, différents, si le premier est supérieur au second, inférieur au second, etc. La comparaison de deux flottants ne ressemble pas beaucoup à celle de deux nombres entiers. Le fait que les flottants sont composés d'un bit de signe, d'un exposant et d'une mantisse, avec un bit implicite, rend les comparaisons plus compliquées.
La seule difficulté est de déterminer le bit implicite des mantisses, du moins semble-t-il. Sauf qu'il faut aussi prendre en compte les '''cas spéciaux''' : les dénormaux, les NaNs, les infinis. Le comparateur flottant doit donc tester si les opérandes, pour vérifier si ce sont des NaNs, des infinis, un zéro, voire un dénormal. Dans ce cas, le résultat de la comparaison est déterminé par un circuit séparé, qui traite ces cas particuliers.
===La comparaison flottante, sans tenir compte des cas spéciaux===
Une solution consiste à compare les bits de signe, les exposants et les mantisses séparément. Les exposants sont encodés avec des nombres entiers (en représentation par excès), idem pour les mantisses. Je viens de dire que les exposants et mantisses sont comparées séparément. Il est possible de les comparer en série : son test d'abord les exposants, et on ne compare les mantisses que s'ils sont égaux. Une autre technique compare les exposants et mantisses en parallèle, et le résultat pour les exposants prend le pas sur le reste.
Une solution alternative compare deux nombres flottants en utilisant les comparaisons pour nombres entiers. L'encodage des flottants fait que l'on peut comparer deux flottants comme s'ils s'agissait d'entiers sans problèmes. Concrètement, un flottant est encodée avec une suite de bits qui peut être interprétée comme un nombre entier. Si on fait ca pour deux flottants, on obtient deux "entiers", qu'on pourra comparer avec un comparateur entier. Et le résultat sera bon, sauf pour certaines opérandes.
Cela vient du fait que deux flottants consécutifs auront un "encodage entier" consécutifs, chose qui est garantie par la norme IEEE754. Si on compare deux flottants, le plus grand des deux est celui qui a l'exposant le plus grand, ou la mantisse la plus grande si les deux exposants sont égaux. L'exposant étant dans les bits de poids fort, on comprend que l'exposant passera en priorité dans la comparaison, et que les mantisses seront comparées si les deux exposants sont égaux. Il ne faut cependant pas oublier la présence du bit de signe, qui impose d'utiliser une comparaison signée, un comparateur en signe-magnitude
===La comparaison flottante pour les cas spéciaux===
Les cas spéciaux sont les NaNs, les zéros, les infinis et les dénormaux. Pour les zéros, la subtilité est qu'il y a deux zéros : +0 et -0. Et les deux doivent être considérés comme égaux. Or, si on compare les deux flottants sans en tenir compte, on se retrouverait avec ''+0 > -0''. Il faut donc ajouter un circuit qui détecte ce cas spécial. Il compare les deux flottants, sans leur bit de sign, pour vérifier si leurs exposants et mantisses valent toutes deux zéros. Si c'est le cas, le bit de signe n'est pas pris en compte.
Pour les NANs et les infinis, ils demandent qu'on détecte quand l’exposant d'un opérande, voire les deux, est à sa valeur maximale.
==Les flottants logarithmiques==
Maintenant, nous allons fabriquer une FPU pour les flottants logarithmiques. Nous avions vu les flottants logarithmiques dans le chapitre [[Fonctionnement d'un ordinateur/Le_codage_des_nombres#Les_nombres_flottants_logarithmiques|Le codage des nombres, dans la section sur les flottants logarithmiques]]. Pour résumer rapidement, ce sont des flottants qui codent uniquement un bit de signe et un exposant, mais sans la mantisse (qui vaut implicitement 1). L'exposant stocké n'est autre que le logarithme en base 2 du nombre codé, d'où le nom donné à ces flottants. Au passage, l'exposant est stocké dans une représentation à virgule fixe. L'utilité de cette représentation est de simplifier fortement les multiplications et divisions, quitte à perdre en performance sur les additions/soustractions.
Pour commencer, il faut savoir que le logarithme d'un produit est égal à la somme des logarithmes. Dans ces conditions, une multiplication entre deux flottants logarithmiques se transforme en une simple addition d'exposants.
: <math>\log (A \times B) = \log A + \log B</math>
Le même raisonnement peut être tenu pour la division, sauf que l'addition est remplacée par une soustraction :
: <math>\log \frac{A}{B} = \log A - \log B</math>
Pour l'addition et la soustraction, le calcul se fait avec cette formule :
: <math>\log_2(x+y) = \log_2(x) + F\left[ \log_2(y) - \log_2(x) \right]</math>, avec F une fonction appelée le logarithme gaussien.
Pour la soustraction, on a la même chose, sauf que la fonction change, ce qui donne :
: <math>\log_2(x - y) = \log_2(x) + G\left[ \log_2(y) - \log_2(x) \right]</math>, avec G une fonction différente de F.
Le calcul demande donc de soustraire les deux opérandes, de calculer la fonction F ou G, puis d'additionner la première opérande. Le toute demande donc un soustracteur, un additionneur et une mémoire de précalcul pour la fonction F/G. Il est possible de mutualiser les additionneurs pour la multiplication et l'addition, en rajoutant quelques multiplexeurs.
[[File:Unité de calcul logarithmique.PNG|centre|vignette|upright=2|Unité de calcul logarithmique]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits de calcul logique et bit à bit
| prevText=Les circuits de calcul logique et bit à bit
| next=Les circuits de calcul trigonométriques
| nextText=Les circuits de calcul trigonométriques
}}
</noinclude>
28viu6j2jxmaiek2q08vmheor89ko25
775311
775310
2026-10-14T19:24:53Z
Mewtow
31375
/* La comparaison flottante pour les cas spéciaux */
775311
wikitext
text/x-wiki
Après avoir vu les circuits de calcul pour les nombres entiers, il est temps de voir les circuits de calculs pour des nombres flottants. Nous allons nous concentrer sur les nombres flottants au format IEEE754, avant de faire un aparté sur les flottants logarithmiques.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Il est souvent dit qu'un processeur incorpore une unité de calcul spécialisée dans les calculs flottants, appelée la ''Floating Point Unit'', ce qui se traduirait en '''unité de calcul flottante'''. Dans la réalité, les processeurs modernes incorporent plusieurs circuits distincts : un pour multiplier deux flottants, un autre pour additionner deux flottants, et éventuellement un troisième pour la division flottante. Ils sont implémentés avec des circuits séparés, comme le sont les ALU et les circuits multiplieurs/diviseurs.
==Les multiplications/divisions flottantes==
Paradoxalement, les multiplications, divisions et racines carrées sont plus simples que l'addition, avec des nombres flottants. Aussi, nous allons d'abord parler des opérations de multiplications et divisions, avant de poursuivre avec les additions et soustractions, en enfin de terminer avec les procédés de normalisation, arrondis et prénormalisation.
Avant le calcul, il y a une étape de '''prénormalisation''', qui gère le bit implicite des mantisses. Elle détermine si ce bit vaut 0 (flottants dénormaux) ou 1 (les flottants normaux), puis l'ajoute aux mantisses. Pour la multiplication et la division, l'étape de prénormalisation ne fait pas autre chose. Mais pour l'addition et la soustraction, elle est plus complexe, comme on le verra plus tard.
===Les circuits multiplieurs/diviseurs flottants===
Prenons deux nombres flottants de mantisses <math>m_1</math> et <math>m_2</math> et les exposants <math>e_1</math> et <math>e_2</math>. Leur multiplication donne :
: <math>(m_1 \times 2^{e_1}) \times (m_2 \times 2^{e_2})</math>
On regroupe les termes :
: <math>(m_1 \times m_2) \times (2^{e_1} \times 2^{e_2})</math>
On simplifie la puissance :
: <math>(m_1 \times m_2) \times 2^{e_1 + e_2}</math>
En clair, multiplier deux flottants revient à multiplier les mantisses et additionner les exposants, ce qui demande un additionneur-soustracteur et un multiplieur. Il faut cependant penser à plusieurs choses pas forcément évidentes.
* Premièrement, il faut ajouter les bits implicites aux mantisses avant de les multiplier, ce qui est le rôle de l'étape de pré-normalisation.
* Deuxièmement, il faut se rappeler que les exposants sont encodés en représentation par excès, ce qui fait qu'il faut utiliser un additionneur-soustracteur en représentation par excès.
* Troisièmement, il faut calculer le bit de signe du résultat à partir de ceux des opérandes.
* Enfin, il ne faut pas oublier de rajouter les étapes de normalisation et d'arrondis, dont on parlera plus bas.
[[File:Multiplieur flottant avec normalisation.PNG|centre|vignette|upright=2|Multiplieur flottant avec normalisation]]
La division fonctionne sur le même principe que la multiplication, si ce n'est que les exposants sont soustraits et que les mantisses sont divisées. Pour le démontrer, prenons deux flottants <math>m_1 \times 2^{e_1}</math> et <math>m_2 \times 2^{e_2}</math> et divisons le premier par le second. On a alors :
: <math>\frac{m1 \times 2^{e_1}}{m2 \times 2^{e_2}}</math>
On applique les règles sur les fractions :
: <math>\frac{m_1}{m_2} \times \frac{2^{e_1}}{2^{e_2}}</math>
On simplifie la puissance de 2 :
: <math>\frac{m_1}{m_2} \times 2^{e_1-e_2}</math>
===La multiplication entière réalisée par l'unité de calcul flottante===
Vous remarquerez qu'un multiplieur flottant contient un multiplieur entier pour multiplier les mantisses. Sachez qu'il est possible de n'utiliser qu'un seul multiplieur entier pour faire à la fois les multiplications entières et flottantes. Cette optimisation a été utilisée sur plusieurs processeurs commerciaux. Par exemple, les processeurs Atom d'Intel utilisaient cette optimisation : les multiplications entières étaient réalisées dans un multiplieur entier partagé avec l'unité de calcul flottante. Il en est de même sur les processeurs Athlon d'AMD, sortis dans les années 2000.
Mais cela ne fonctionnait que sur les processeurs 32 bits, pas sur les 64 bits. Pour comprendre pourquoi, il faut savoir que les processeurs modernes gèrent des nombres flottants simple et double précision, à savoir codés sur 32 et 64 bits. Il y a un seul multiplieur, capable de gérer des flottants double précision, qui peut le plus peut le moins. Avec des flottants double précision de 64 bits, les mantisses font 52 bits, ce qui fait un multiplieur de 53 bits. C'est suffisant pour un processeur 32 bits, pas assez sur un processeur 64 bits.
Ajoutons cependant une petite nuance. Sur les anciens processeurs x86 des PC, les flottants faisaient 80 bits, avec une mantisse de 64 bits, ce qui est assez à la fois pour les processeurs 32 et 64 bits. Malheureusement, les processeurs 64 bits avaient un budget en transistor suffisant pour ne pas appliquer cette optimisation. Pour des raisons diverses, il était préférable d'avoir un multiplieur entier séparé du multiplieur flottant.
==L'addition et la soustraction flottante==
La somme de deux flottants se calcule simplement si les exposants des deux opérandes sont égaux : il suffit alors d'additionner les mantisses. Mais que faire si les deux exposants sont différents ? L'astuce est de mettre les deux flottants au même exposant sans en changer leur valeur, de les mettre à l'échelle. L'exposant choisi étant souvent le plus grand exposant des deux flottants. Une fois mises à l'échelle, les deux opérandes sont additionnées, et le résultat est normalisé pour donner un flottant.
Suivant les signes, il faudra additionner ou soustraire les opérandes : additionner une opérande positive avec une négative demande en réalité de faire une soustraction, de même que soustraire une opérande négative demande en réalité de l'additionner. Il faut donc ajouter, avant l'additionneur, un circuit qui détermine s'il faut faire une addition ou une soustraction, en fonction du bit de signe des opérandes, et de s'il faut faire une addition ou une soustraction (opcode de l'opération voulue).
[[File:Crcuit d'addition et de soustraction flottante.jpg|centre|vignette|upright=2|Circuit d'addition et de soustraction flottante.]]
===Le circuit de pré-normalisation===
La mise des deux opérandes au même exposant s'appelle la '''pré-normalisation'''. L'exposant final est choisit parmi les deux opérandes : on prend le plus grand exposant parmi des deux. L'opérande avec le plus grand exposant reste inchangée, elle est conservée telle quelle. Par contre, il faut pré-normaliser l'autre opérande, celui avec le plus petit exposant. Et pour cela, rien de plus simple : il suffit de décaler la mantisse vers la droite, d'un nombre de rangs égal à la différence entre les deux exposants.
Pour faire ce décalage, on utilise un décaleur et un circuit qui échange les deux opérandes. Le circuit d'échange a pour but d'envoyer le plus petit exposant dans le décaleur et est composé de quelques multiplexeurs. Il est piloté par un comparateur qui détermine quel est le nombre avec le plus petit exposant. Nous verrons comment fabriquer un tel comparateur dans le chapitre suivant sur les comparateurs.
[[File:Circuit de mise au même exposant.jpg|centre|vignette|upright=2|Circuit de mise au même exposant.]]
Précisons que le comparateur et le soustracteur peuvent être fusionnés, car un comparateur est en réalité un soustracteur amélioré. Une manière alternative est la suivante. En premier lieu, on soustrait les exposants pour déterminer de combien décaler la mantisse. Le résultat de la soustraction est ensuite envoyé à un circuit qui vérifie si le résultat est positif ou négatif, en vérifiant le bit de poids fort du résultat. Si le résultat est positif, la première opérande est plus grande que la seconde, c'est la seconde opérande qu'il faut pré-normaliser. Si le résultat est négatif, c'est la première opérande qu'il faut prénormaliser.
[[File:Circuit de prénormalisation d'un additionneur flottant.jpg|centre|vignette|upright=2|Circuit de prénormalisation d'un additionneur flottant]]
==La normalisation et les arrondis==
Calculer sur des nombres flottants peut sembler trivial, mais les mathématiques ne sont pas vraiment d'accord avec cela. En effet, le résultat d'un calcul avec des flottants n'est pas forcément un flottant valide. Il doit subir quelques transformations pour être un nombre flottant : il doit souvent être arrondi, et doit auissi passer par d'autres étapes dites de normalisation.
[[File:Normalisation in circuit.png|vignette|upright=1|Normalisation in circuit]]
Elles corrigent le résultat du calcul pour qu'il rentre dans un nombre flottant. Par exemple, si on multiplie deux flottants de 32 bits, l'exposant et la mantisse du résultat sont calculés séparément et les concaténer ne donne pas forcément un nombre flottant 32 bits. Diverses techniques de normalisation et d'arrondis permettent de corriger l'exposant et la mantisse pour donner un flottant 32 bit correct. Et elles auront leur section dédiée.
La normalisation et les arrondis sont gérés différemment suivant le format de flottant utilisé. Les flottants les plus courants suivent la norme IEEE754, où normalisation et arrondis sont standardisés. Mais d'autres formats de flottants exotiques peuvent suivre des règles différentes.
===La normalisation===
La '''normalisation''' gère le bit implicite. Le résultat en sortie d'un circuit de calcul n'a pas forcément son bit implicite à 1. Prenons l'exemple suivant, où on soustrait deux flottants qui ont des mantisses codées sur 8 bits - le format de flottant n'est donc par standard. On soustrait les deux mantisses suivantes, le chiffre entre parenthèse est le bit implicite : (1) 1100 1100 - (1) 1000 1000 = (0) 0100 0100.
Le résultat a un bit implicite à 0, ce qui donne un résultat dénormal. Mais il est parfois possible de convertir ce résultat en un flottant normal, à condition de corriger l'exposant. L'idée est, pour le cas précédent, de décaler la mantisse de deux rangs : (0) 0100 0100 devient (1) 0001 00''00''. Mais décaler la mantisse déforme le résultat : le résultat décalé de deux rangs vers la gauche multiplie le résultat par 4. Mais on peut compenser exactement le tout en corrigeant l'exposant, afin de diviser le résultat final par 4 : il suffit de soustraire deux à l'exposant !
Le cas général est assez similaire, sauf que l'on doit décaler la mantisse par un nombre de rang adéquat, pas forcément 2, et soustraire ce nombre de rangs à l'exposant. Pour savoir de combien de rangs il faut décaler, il faut compter le nombre de zéros situés de poids fort, avec un circuit spécialisé qu'on a vu il y a quelques chapitres, le circuit de CLZ (''Count Leading Zero''). Ce circuit permet aussi de détecter si la mantisse vaut zéro.
[[File:Circuit de prénormalisation.jpg|centre|vignette|upright=2|Circuit de normalisation.]]
===Les arrondis===
Une fois ce résultat calculé, il faut faire un arrondi du résultat avec un circuit d''''arrondi'''. L'arrondi se base sur les bits de poids faible situés juste à gauche et à droite de la virgule., ce qui demande d'analyser une dizaine de bits tout au plus. Une fois les bits de poids faible à gauche de la virgule sont remplacé, les bits à droite sont éliminés.
L'arrondi peut être réalisé par un circuit combinatoire, mais le faible nombre de bits d'entrée rend possible d'utiliser ce qui s'appelle une '''mémoire de précalcul'''. Avec cette technique, le circuit combinatoire est remplacé par une ROM équivalente. Les entrées du circuit combinatoire deviennent l'entrée d'adresse de la ROM, les sorties du circuit deviennent la sortie de donnée de la ROM. Pour une entrée identique, les deux donneront les mêmes sorties.
[[File:Circuit d'arrondi flottant basé sur une ROM.png|centre|vignette|upright=1.5|Circuit d'arrondi flottant basé sur une ROM.]]
Malheureusement, il arrive que ces arrondis décalent la position du bit implicite d'un rang, ce qui se résout avec un décalage si cela arrive. Le circuit de normalisation contient donc de quoi détecter ces débordements et un décaleur. Bien évidemment, l'exposant doit alors lui aussi être corrigé en cas de décalage de la mantisse.
[[File:Circuit de postnormalisation.jpg|centre|vignette|upright=2|Circuit de postnormalisation.]]
==La comparaison de deux flottants==
Nous venons de voir deux opérations arithmétiques : l'addition et la multiplication. Mais nous n'avons pas vu une opération moins intuitive ; les comparaisons ! Il est en effet possible de comparer deux nombres flottants, pour savoir si ils sont égaux, différents, si le premier est supérieur au second, inférieur au second, etc. La comparaison de deux flottants ne ressemble pas beaucoup à celle de deux nombres entiers. Le fait que les flottants sont composés d'un bit de signe, d'un exposant et d'une mantisse, avec un bit implicite, rend les comparaisons plus compliquées.
La seule difficulté est de déterminer le bit implicite des mantisses, du moins semble-t-il. Sauf qu'il faut aussi prendre en compte les '''cas spéciaux''' : les dénormaux, les NaNs, les infinis. Le comparateur flottant doit donc tester si les opérandes, pour vérifier si ce sont des NaNs, des infinis, un zéro, voire un dénormal. Dans ce cas, le résultat de la comparaison est déterminé par un circuit séparé, qui traite ces cas particuliers.
===La comparaison flottante, sans tenir compte des cas spéciaux===
Une solution consiste à compare les bits de signe, les exposants et les mantisses séparément. Les exposants sont encodés avec des nombres entiers (en représentation par excès), idem pour les mantisses. Je viens de dire que les exposants et mantisses sont comparées séparément. Il est possible de les comparer en série : son test d'abord les exposants, et on ne compare les mantisses que s'ils sont égaux. Une autre technique compare les exposants et mantisses en parallèle, et le résultat pour les exposants prend le pas sur le reste.
Une solution alternative compare deux nombres flottants en utilisant les comparaisons pour nombres entiers. L'encodage des flottants fait que l'on peut comparer deux flottants comme s'ils s'agissait d'entiers sans problèmes. Concrètement, un flottant est encodée avec une suite de bits qui peut être interprétée comme un nombre entier. Si on fait ca pour deux flottants, on obtient deux "entiers", qu'on pourra comparer avec un comparateur entier. Et le résultat sera bon, sauf pour certaines opérandes.
Cela vient du fait que deux flottants consécutifs auront un "encodage entier" consécutifs, chose qui est garantie par la norme IEEE754. Si on compare deux flottants, le plus grand des deux est celui qui a l'exposant le plus grand, ou la mantisse la plus grande si les deux exposants sont égaux. L'exposant étant dans les bits de poids fort, on comprend que l'exposant passera en priorité dans la comparaison, et que les mantisses seront comparées si les deux exposants sont égaux. Il ne faut cependant pas oublier la présence du bit de signe, qui impose d'utiliser une comparaison signée, un comparateur en signe-magnitude
===La comparaison flottante pour les cas spéciaux===
Les cas spéciaux sont les NaNs, les zéros, les infinis et les dénormaux. Pour les zéros, la subtilité est qu'il y a deux zéros : +0 et -0. Et les deux doivent être considérés comme égaux. Or, si on compare les deux flottants sans en tenir compte, on se retrouverait avec ''+0 > -0''. Il faut donc ajouter un circuit qui détecte ce cas spécial. Il compare les deux flottants, sans leur bit de sign, pour vérifier si leurs exposants et mantisses valent toutes deux zéros. Si c'est le cas, le bit de signe n'est pas pris en compte.
Pour les NANs et les infinis, ils demandent qu'on détecte quand l’exposant d'un opérande, voire les deux, est à sa valeur maximale. La différence est que les Nans ont une mantisse non-nulle, et les infinis une mantisse nulle. Là encore, on réutilise le comparateur qui vérifie si les exposants/mantisses valent zéro, utilisé pour gérer les zéros flottants.
==Les flottants logarithmiques==
Maintenant, nous allons fabriquer une FPU pour les flottants logarithmiques. Nous avions vu les flottants logarithmiques dans le chapitre [[Fonctionnement d'un ordinateur/Le_codage_des_nombres#Les_nombres_flottants_logarithmiques|Le codage des nombres, dans la section sur les flottants logarithmiques]]. Pour résumer rapidement, ce sont des flottants qui codent uniquement un bit de signe et un exposant, mais sans la mantisse (qui vaut implicitement 1). L'exposant stocké n'est autre que le logarithme en base 2 du nombre codé, d'où le nom donné à ces flottants. Au passage, l'exposant est stocké dans une représentation à virgule fixe. L'utilité de cette représentation est de simplifier fortement les multiplications et divisions, quitte à perdre en performance sur les additions/soustractions.
Pour commencer, il faut savoir que le logarithme d'un produit est égal à la somme des logarithmes. Dans ces conditions, une multiplication entre deux flottants logarithmiques se transforme en une simple addition d'exposants.
: <math>\log (A \times B) = \log A + \log B</math>
Le même raisonnement peut être tenu pour la division, sauf que l'addition est remplacée par une soustraction :
: <math>\log \frac{A}{B} = \log A - \log B</math>
Pour l'addition et la soustraction, le calcul se fait avec cette formule :
: <math>\log_2(x+y) = \log_2(x) + F\left[ \log_2(y) - \log_2(x) \right]</math>, avec F une fonction appelée le logarithme gaussien.
Pour la soustraction, on a la même chose, sauf que la fonction change, ce qui donne :
: <math>\log_2(x - y) = \log_2(x) + G\left[ \log_2(y) - \log_2(x) \right]</math>, avec G une fonction différente de F.
Le calcul demande donc de soustraire les deux opérandes, de calculer la fonction F ou G, puis d'additionner la première opérande. Le toute demande donc un soustracteur, un additionneur et une mémoire de précalcul pour la fonction F/G. Il est possible de mutualiser les additionneurs pour la multiplication et l'addition, en rajoutant quelques multiplexeurs.
[[File:Unité de calcul logarithmique.PNG|centre|vignette|upright=2|Unité de calcul logarithmique]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits de calcul logique et bit à bit
| prevText=Les circuits de calcul logique et bit à bit
| next=Les circuits de calcul trigonométriques
| nextText=Les circuits de calcul trigonométriques
}}
</noinclude>
42t1cr603h1ivggnvhkqi9dxe8qe7os
Mathc complexes/a29
0
79761
775315
770633
2026-10-15T08:47:52Z
Xhungab
23827
775315
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc complexes (livre)]]
:
[[Mathc complexes/Sommaire#La bibliothèque| Sommaire]]
:
:
----{{Partie{{{type|}}}| '''La bibliothèque'''|fond={{{fond|}}<nowiki>}</nowiki>}}
:
:
* [https://youtube.com/playlist?list=PLDJeH6AzpEeE&si=KTqEEThg0dR_zUEx '''Playlist''']
:
:
{{Partie{{{type|}}}|[[Mathc complexes/a02e|'''Utilitaires''']]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02a| Matrices]]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02b| '''Déterminant''']]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02c| GaussJordan]]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02d| '''Produit scalaire''']]}}
:
.
:
{{AutoCat}}
sde9k88rg85nsk6ohbcickjo01xsb0e
775316
775315
2026-10-15T08:51:28Z
Xhungab
23827
775316
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc complexes (livre)]]
:
[[Mathc complexes/Sommaire#La bibliothèque| Sommaire]]
:
:
----{{Partie{{{type|}}}| '''La bibliothèque'''|fond={{{fond|}}<nowiki>}</nowiki>}}
:
:
* Cette [https://youtube.com/playlist?list=PLDJeH6AzpEeE&si=KTqEEThg0dR_zUEx '''Playlist'''] sur les matrices réelles vous présente ma méthode de travail pour télécharger la bibliothèque.
:
:
{{Partie{{{type|}}}|[[Mathc complexes/a02e|'''Utilitaires''']]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02a| Matrices]]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02b| '''Déterminant''']]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02c| GaussJordan]]}}
:
{{Partie{{{type|}}}|[[Mathc complexes/a02d| '''Produit scalaire''']]}}
:
.
:
{{AutoCat}}
rqzo1cz79uadef3mvh7sod0bxvluzg2
Livre de cuisine/Risotto au potimarron
0
84509
775312
772210
2026-10-14T23:49:55Z
Cdang
1202
/* Préparation */ ne pas peler
775312
wikitext
text/x-wiki
{{SansGluten}}
{{Livre de cuisine}}
== Ingrédients ==
* 350 kg de {{i|riz}} rond
* 500 g de {{i|potimarron}}
* 1 oignon blanc
* ½ ℓ de bouillon de légume
* 1 verre de lait, ou de la crème fraîche
* {{i|parmesan}} râpé, typiquement une cuiller à soupe par personne
* persil haché
== Préparation ==
* Laver le potimarron, le couper en deux et enlever les graines. Le couper en tranches fines. La peau étant fine, il est inutile de le peler (c'est aussi le cas d'autre courges comme le butternut, si vous voulez varier la recette ).
* Hacher menu l'oignon et le faire revenir dans 20 g de beurre.
* Ajouter le potimarron.
* Faire cuire à feux doux en ajoutant un peu de bouillon, en mélangeant. Saler éventuellement.
* Quand le potimarron est défait, ajouter le riz et faire cuire une vingtaine de minutes en ajoutant le bouillon chaud au fur et à mesure. Remuer, le riz doit boire le bouillon à chaque ajout.
* Ajouter le lait et faire cuire à feu vif.
* Ajouter le parmesan et le persil, éventuellement poivrer.
{{DEFAULTSORT:risotto de potimarron}}
[[Catégorie:Recettes végétariennes]]
9d9cp6gebs9sd0o682ruinj2b3uw15l