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/L'abstraction mémoire et la mémoire virtuelle
0
65813
775244
774401
2026-10-13T21:57:09Z
Mewtow
31375
/* L'abstraction matérielle des processus : une table des pages par processus */
775244
wikitext
text/x-wiki
Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas.
Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''.
L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux.
==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires==
La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus.
: En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''.
A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments.
De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre.
La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi.
En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
[[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]]
Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions.
: Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier.
Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique.
===La mémoire virtuelle avec la relocation matérielle===
Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place.
Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments.
L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire.
Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle.
===L'extension d'adressage avec la relocation matérielle===
Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur.
L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16.
Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques.
==La segmentation en mode réel des processeurs x86==
Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque.
Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel.
===Les segments en mode réel===
[[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]]
La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts.
* Le segment '''''text''''', qui contient le code machine du programme, de taille fixe.
* Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc.
* Le segment pour la '''pile''', de taille variable.
* le reste est appelé le '''tas''', de taille variable.
Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation.
===Les registres de segments en mode réel===
Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme.
Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment.
Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS.
Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES.
Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES.
===La traduction d'adresse en mode réel===
La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir.
[[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]]
L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets.
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">0000 1000 0001 0010 0100</code>
| Adresse finale
| 20 bits
|}
Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs.
Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions.
Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant.
[[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]]
La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]]
===La segmentation en mode réel accepte plusieurs segments de code/données===
Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB.
Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi.
Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur.
Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas.
La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''.
===L'occupation de l'espace d'adressage par les segments===
Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste :
{| class="wikitable"
|-
! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés
|-
| Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement
|-
| Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far''
|-
| Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far''
|}
Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement.
[[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]]
Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents.
L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM.
Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment.
===Le mode réel sur les 286 et plus : la ligne d'adresse A20===
Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut :
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code>
| Adresse finale
| 20 bits
|}
Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après.
Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM.
[[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]]
Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA.
{| class="wikitable"
|+ Espace d'adressage du 286
|-
! Adresses en héxadécimal !! Zone de mémoire
|-
| 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet
|-
| 10 0000 à 10 FFEF || ''High Memory Area''
|-
| 0 à 0F FFFF || Mémoire adressable en mode réel
|}
En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus.
Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs.
Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse.
: Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé.
Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086.
==La segmentation avec une table des segments==
La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée.
===Pourquoi plusieurs segments par programme ?===
L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction.
[[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]]
L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle.
Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile.
L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''.
La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment.
Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme.
===Les tables de segments avec la segmentation===
La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment.
Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme.
Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''.
Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire.
Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales.
Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales.
===La relocation avec la segmentation===
La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire.
La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau.
[[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]]
Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM.
Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations.
[[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]]
L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire.
Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale.
[[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]]
Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat.
[[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]]
Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement.
Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation.
Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''.
Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation.
Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''.
L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur.
Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits.
Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits.
===La mémoire virtuelle avec la segmentation===
La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation.
Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés.
Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules.
L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments.
L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS.
===Le partage de segments===
Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple.
La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts.
Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique.
Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque.
[[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]]
Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts.
Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité.
===L'extension d'adresse avec la segmentation===
L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme.
Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage.
===Le mode protégé des processeurs x86===
L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM.
Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286.
Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs.
Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale.
Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle.
Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient :
* le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ;
* deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ;
* un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données.
* un champ Type qui contient les bits suivants :
** un bit E qui indique si le segment contient du code exécutable ou non ;
** le bit RW qui indique s'il est en lecture seule ou non ;;
** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS;
** un bit DC assez spécifiques.
En haut à gauche, en bleu, on trouve deux bits :
* Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination.
* Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits.
[[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]]
Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit :
* 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ;
* un bit qui précise s'il faut accéder à la table des segments globale ou locale ;
* deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur).
[[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]]
En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale.
====La MMU du 386/486 : cache de segment, protection mémoire====
La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286.
L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière.
Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante.
Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite.
[[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]]
En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits.
Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache.
====L'implémentation de la protection mémoire sur le 386====
La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait.
Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode.
Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode.
{|class="wikitable"
|+ Entrée de la ''Protection Test Unit''
|-
! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0
|-
| P1 , P2 || || P || S || X || E || R/W || A || Code opération
|-
| Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération
|}
Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire.
Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires.
Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles.
Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour.
====Le ''Hardware task switching'' des CPU x86====
Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres.
Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires.
Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé.
Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''.
Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante :
* Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé.
* Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur.
En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur.
[[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]]
===La segmentation sur les processeurs Burrough B5000 et plus===
Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir.
Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough.
La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT.
La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire.
La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques.
Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé.
: L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème.
[[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]]
==Les architectures à capacités==
Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014.
===Le partage de la mémoire sur les architectures à capacités===
Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants.
[[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]]
À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise.
Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution.
===Les capacités sont des pointeurs protégés===
Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment.
Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée.
[[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]]
Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu.
La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau).
La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau.
Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas.
Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé.
====La liste des capacités====
Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle.
[[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]]
La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments.
Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc.
L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante.
====Les capacités dispersées, les architectures taguées====
Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés.
Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée.
[[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]]
L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué.
===Les registres de capacité===
Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres.
Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''.
Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre.
Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit.
===Le recyclage de mémoire matériel===
Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée.
Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons.
Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles.
Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''.
Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur.
===L'intel iAPX 432===
Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080.
La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais.
====Les segments prédéfinis de l'Intel iAPX 432====
L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire !
Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment.
Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation :
* Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''.
* Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données.
* Des segments de domaine, pour les modules ou bibliothèques dynamiques.
* Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte.
* Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages.
* Et bien d'autres encores.
Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc.
L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non.
====Le support de la segmentation sur l'Intel iAPX 432====
La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''.
Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle.
Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments.
====Le jeu d'instruction de l'Intel iAPX 432====
L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc.
Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non.
le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales.
Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière.
* Le premier est l'opcode de l'instruction.
* Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé.
* Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile.
* Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles.
[[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]]
====Le support de l'orienté objet sur l'Intel iAPX 432====
L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture.
L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter.
Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager.
===Conclusion===
Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) :
* [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems].
Voici un document qui décrit le fonctionnement de l'Intel iAPX432 :
* [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ]
==La pagination==
Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique.
L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0.
[[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]]
Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86.
Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici.
===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires===
Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages''
[[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]]
De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur.
====Les défauts de page====
Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison.
Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci.
Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse.
Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver.
Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique.
Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation.
====Le remplacement des pages====
Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans.
Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''.
Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde.
Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''.
====Les algorithmes de remplacement des pages pris en charge par l'OS====
Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants.
Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes.
L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général.
L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter.
L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude.
Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1.
Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique.
Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive.
===La protection mémoire avec la pagination===
Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page.
Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS.
Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit.
Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter.
===La traduction d'adresse avec la pagination===
Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''.
Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible.
Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique.
[[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]]
====Les tables des pages simples====
Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder.
[[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]]
La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique.
[[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]]
====Les tables des pages inversées====
Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable.
Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle.
Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC.
[[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]]
====Les tables des pages multiples par espace d'adressage====
Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses.
Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée.
L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau.
L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale.
[[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]]
On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante.
====L'exemple des processeurs x86====
Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus.
Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille).
[[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]]
Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets).
[[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]]
La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant.
La table des pages gardait 2 niveaux pour les pages larges en PAE.
[[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]]
Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois.
[[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]]
En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés.
[[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]]
====Les circuits liés à la gestion de la table des pages====
En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages.
[[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]]
Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut.
Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers).
===L'abstraction matérielle des processus : une table des pages par processus===
[[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]]
Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes.
La première solution n'utilise qu'une seule table des pages. Pour cela, chaque entrée indique à quel processus elle est associée. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. Pour cela, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus, l'identifiant de processus est ignoré. Un défaut est que le partage d'une page entre plusieurs processus est impossible : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
L'intérêt de partager des pages entre tous les processus n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Emulée via relocation logicielle au démarrage du programme.
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
==Un cas particulier : la z/architecture d'IBM==
IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites.
La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum).
Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu".
===Le mode standard de la ''z/architecture''===
En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes.
: Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit.
Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage.
L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire".
===Les registres d'accès de la ''z/architecture''===
Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''.
Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7.
Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ?
Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait.
===L'utilisation des registres d'accès===
La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme".
La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même.
[[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]]
Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit.
Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant.
Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables.
{|class="wikitable"
|+ Registre d'accès
|-
! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN
|-
| 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée
|}
Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5.
: Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire.
[[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]]
Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée.
Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité.
* Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments.
* Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation.
* Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages.
* Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
8m5qa9dhxf3pmm6za9b958sqi8a4i5v
775245
775244
2026-10-13T21:57:43Z
Mewtow
31375
/* Les circuits liés à la gestion de la table des pages */
775245
wikitext
text/x-wiki
Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas.
Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''.
L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux.
==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires==
La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus.
: En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''.
A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments.
De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre.
La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi.
En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
[[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]]
Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions.
: Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier.
Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique.
===La mémoire virtuelle avec la relocation matérielle===
Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place.
Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments.
L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire.
Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle.
===L'extension d'adressage avec la relocation matérielle===
Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur.
L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16.
Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques.
==La segmentation en mode réel des processeurs x86==
Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque.
Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel.
===Les segments en mode réel===
[[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]]
La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts.
* Le segment '''''text''''', qui contient le code machine du programme, de taille fixe.
* Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc.
* Le segment pour la '''pile''', de taille variable.
* le reste est appelé le '''tas''', de taille variable.
Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation.
===Les registres de segments en mode réel===
Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme.
Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment.
Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS.
Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES.
Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES.
===La traduction d'adresse en mode réel===
La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir.
[[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]]
L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets.
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">0000 1000 0001 0010 0100</code>
| Adresse finale
| 20 bits
|}
Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs.
Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions.
Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant.
[[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]]
La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]]
===La segmentation en mode réel accepte plusieurs segments de code/données===
Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB.
Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi.
Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur.
Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas.
La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''.
===L'occupation de l'espace d'adressage par les segments===
Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste :
{| class="wikitable"
|-
! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés
|-
| Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement
|-
| Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far''
|-
| Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far''
|}
Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement.
[[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]]
Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents.
L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM.
Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment.
===Le mode réel sur les 286 et plus : la ligne d'adresse A20===
Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut :
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code>
| Adresse finale
| 20 bits
|}
Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après.
Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM.
[[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]]
Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA.
{| class="wikitable"
|+ Espace d'adressage du 286
|-
! Adresses en héxadécimal !! Zone de mémoire
|-
| 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet
|-
| 10 0000 à 10 FFEF || ''High Memory Area''
|-
| 0 à 0F FFFF || Mémoire adressable en mode réel
|}
En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus.
Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs.
Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse.
: Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé.
Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086.
==La segmentation avec une table des segments==
La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée.
===Pourquoi plusieurs segments par programme ?===
L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction.
[[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]]
L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle.
Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile.
L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''.
La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment.
Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme.
===Les tables de segments avec la segmentation===
La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment.
Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme.
Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''.
Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire.
Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales.
Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales.
===La relocation avec la segmentation===
La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire.
La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau.
[[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]]
Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM.
Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations.
[[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]]
L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire.
Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale.
[[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]]
Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat.
[[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]]
Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement.
Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation.
Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''.
Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation.
Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''.
L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur.
Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits.
Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits.
===La mémoire virtuelle avec la segmentation===
La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation.
Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés.
Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules.
L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments.
L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS.
===Le partage de segments===
Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple.
La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts.
Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique.
Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque.
[[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]]
Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts.
Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité.
===L'extension d'adresse avec la segmentation===
L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme.
Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage.
===Le mode protégé des processeurs x86===
L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM.
Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286.
Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs.
Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale.
Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle.
Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient :
* le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ;
* deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ;
* un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données.
* un champ Type qui contient les bits suivants :
** un bit E qui indique si le segment contient du code exécutable ou non ;
** le bit RW qui indique s'il est en lecture seule ou non ;;
** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS;
** un bit DC assez spécifiques.
En haut à gauche, en bleu, on trouve deux bits :
* Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination.
* Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits.
[[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]]
Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit :
* 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ;
* un bit qui précise s'il faut accéder à la table des segments globale ou locale ;
* deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur).
[[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]]
En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale.
====La MMU du 386/486 : cache de segment, protection mémoire====
La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286.
L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière.
Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante.
Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite.
[[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]]
En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits.
Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache.
====L'implémentation de la protection mémoire sur le 386====
La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait.
Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode.
Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode.
{|class="wikitable"
|+ Entrée de la ''Protection Test Unit''
|-
! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0
|-
| P1 , P2 || || P || S || X || E || R/W || A || Code opération
|-
| Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération
|}
Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire.
Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires.
Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles.
Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour.
====Le ''Hardware task switching'' des CPU x86====
Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres.
Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires.
Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé.
Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''.
Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante :
* Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé.
* Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur.
En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur.
[[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]]
===La segmentation sur les processeurs Burrough B5000 et plus===
Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir.
Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough.
La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT.
La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire.
La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques.
Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé.
: L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème.
[[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]]
==Les architectures à capacités==
Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014.
===Le partage de la mémoire sur les architectures à capacités===
Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants.
[[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]]
À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise.
Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution.
===Les capacités sont des pointeurs protégés===
Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment.
Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée.
[[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]]
Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu.
La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau).
La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau.
Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas.
Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé.
====La liste des capacités====
Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle.
[[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]]
La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments.
Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc.
L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante.
====Les capacités dispersées, les architectures taguées====
Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés.
Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée.
[[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]]
L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué.
===Les registres de capacité===
Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres.
Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''.
Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre.
Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit.
===Le recyclage de mémoire matériel===
Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée.
Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons.
Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles.
Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''.
Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur.
===L'intel iAPX 432===
Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080.
La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais.
====Les segments prédéfinis de l'Intel iAPX 432====
L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire !
Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment.
Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation :
* Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''.
* Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données.
* Des segments de domaine, pour les modules ou bibliothèques dynamiques.
* Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte.
* Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages.
* Et bien d'autres encores.
Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc.
L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non.
====Le support de la segmentation sur l'Intel iAPX 432====
La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''.
Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle.
Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments.
====Le jeu d'instruction de l'Intel iAPX 432====
L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc.
Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non.
le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales.
Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière.
* Le premier est l'opcode de l'instruction.
* Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé.
* Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile.
* Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles.
[[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]]
====Le support de l'orienté objet sur l'Intel iAPX 432====
L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture.
L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter.
Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager.
===Conclusion===
Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) :
* [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems].
Voici un document qui décrit le fonctionnement de l'Intel iAPX432 :
* [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ]
==La pagination==
Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique.
L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0.
[[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]]
Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86.
Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici.
===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires===
Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages''
[[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]]
De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur.
====Les défauts de page====
Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison.
Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci.
Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse.
Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver.
Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique.
Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation.
====Le remplacement des pages====
Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans.
Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''.
Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde.
Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''.
====Les algorithmes de remplacement des pages pris en charge par l'OS====
Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants.
Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes.
L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général.
L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter.
L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude.
Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1.
Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique.
Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive.
===La protection mémoire avec la pagination===
Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page.
Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS.
Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit.
Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter.
===La traduction d'adresse avec la pagination===
Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''.
Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible.
Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique.
[[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]]
====Les tables des pages simples====
Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder.
[[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]]
La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique.
[[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]]
====Les tables des pages inversées====
Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable.
Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle.
Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC.
[[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]]
====Les tables des pages multiples par espace d'adressage====
Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses.
Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée.
L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau.
L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale.
[[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]]
On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante.
====L'exemple des processeurs x86====
Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus.
Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille).
[[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]]
Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets).
[[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]]
La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant.
La table des pages gardait 2 niveaux pour les pages larges en PAE.
[[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]]
Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois.
[[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]]
En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés.
[[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]]
====La TLB et les caches associés===
En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages.
[[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]]
Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut.
Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers).
===L'abstraction matérielle des processus : une table des pages par processus===
[[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]]
Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes.
La première solution n'utilise qu'une seule table des pages. Pour cela, chaque entrée indique à quel processus elle est associée. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. Pour cela, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus, l'identifiant de processus est ignoré. Un défaut est que le partage d'une page entre plusieurs processus est impossible : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
L'intérêt de partager des pages entre tous les processus n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Emulée via relocation logicielle au démarrage du programme.
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
==Un cas particulier : la z/architecture d'IBM==
IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites.
La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum).
Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu".
===Le mode standard de la ''z/architecture''===
En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes.
: Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit.
Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage.
L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire".
===Les registres d'accès de la ''z/architecture''===
Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''.
Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7.
Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ?
Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait.
===L'utilisation des registres d'accès===
La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme".
La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même.
[[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]]
Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit.
Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant.
Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables.
{|class="wikitable"
|+ Registre d'accès
|-
! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN
|-
| 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée
|}
Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5.
: Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire.
[[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]]
Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée.
Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité.
* Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments.
* Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation.
* Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages.
* Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
nsk0sl25h0k0hf5lh2xqyqkimpep95b
775246
775245
2026-10-13T21:57:52Z
Mewtow
31375
/* =La TLB et les caches associés */
775246
wikitext
text/x-wiki
Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas.
Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''.
L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux.
==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires==
La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus.
: En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''.
A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments.
De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre.
La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi.
En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
[[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]]
Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions.
: Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier.
Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique.
===La mémoire virtuelle avec la relocation matérielle===
Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place.
Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments.
L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire.
Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle.
===L'extension d'adressage avec la relocation matérielle===
Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur.
L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16.
Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques.
==La segmentation en mode réel des processeurs x86==
Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque.
Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel.
===Les segments en mode réel===
[[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]]
La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts.
* Le segment '''''text''''', qui contient le code machine du programme, de taille fixe.
* Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc.
* Le segment pour la '''pile''', de taille variable.
* le reste est appelé le '''tas''', de taille variable.
Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation.
===Les registres de segments en mode réel===
Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme.
Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment.
Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS.
Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES.
Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES.
===La traduction d'adresse en mode réel===
La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir.
[[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]]
L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets.
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">0000 1000 0001 0010 0100</code>
| Adresse finale
| 20 bits
|}
Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs.
Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions.
Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant.
[[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]]
La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]]
===La segmentation en mode réel accepte plusieurs segments de code/données===
Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB.
Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi.
Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur.
Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas.
La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''.
===L'occupation de l'espace d'adressage par les segments===
Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste :
{| class="wikitable"
|-
! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés
|-
| Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement
|-
| Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far''
|-
| Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far''
|}
Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement.
[[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]]
Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents.
L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM.
Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment.
===Le mode réel sur les 286 et plus : la ligne d'adresse A20===
Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut :
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code>
| Adresse finale
| 20 bits
|}
Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après.
Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM.
[[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]]
Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA.
{| class="wikitable"
|+ Espace d'adressage du 286
|-
! Adresses en héxadécimal !! Zone de mémoire
|-
| 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet
|-
| 10 0000 à 10 FFEF || ''High Memory Area''
|-
| 0 à 0F FFFF || Mémoire adressable en mode réel
|}
En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus.
Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs.
Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse.
: Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé.
Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086.
==La segmentation avec une table des segments==
La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée.
===Pourquoi plusieurs segments par programme ?===
L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction.
[[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]]
L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle.
Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile.
L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''.
La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment.
Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme.
===Les tables de segments avec la segmentation===
La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment.
Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme.
Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''.
Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire.
Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales.
Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales.
===La relocation avec la segmentation===
La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire.
La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau.
[[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]]
Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM.
Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations.
[[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]]
L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire.
Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale.
[[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]]
Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat.
[[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]]
Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement.
Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation.
Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''.
Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation.
Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''.
L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur.
Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits.
Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits.
===La mémoire virtuelle avec la segmentation===
La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation.
Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés.
Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules.
L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments.
L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS.
===Le partage de segments===
Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple.
La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts.
Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique.
Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque.
[[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]]
Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts.
Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité.
===L'extension d'adresse avec la segmentation===
L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme.
Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage.
===Le mode protégé des processeurs x86===
L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM.
Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286.
Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs.
Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale.
Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle.
Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient :
* le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ;
* deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ;
* un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données.
* un champ Type qui contient les bits suivants :
** un bit E qui indique si le segment contient du code exécutable ou non ;
** le bit RW qui indique s'il est en lecture seule ou non ;;
** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS;
** un bit DC assez spécifiques.
En haut à gauche, en bleu, on trouve deux bits :
* Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination.
* Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits.
[[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]]
Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit :
* 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ;
* un bit qui précise s'il faut accéder à la table des segments globale ou locale ;
* deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur).
[[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]]
En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale.
====La MMU du 386/486 : cache de segment, protection mémoire====
La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286.
L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière.
Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante.
Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite.
[[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]]
En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits.
Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache.
====L'implémentation de la protection mémoire sur le 386====
La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait.
Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode.
Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode.
{|class="wikitable"
|+ Entrée de la ''Protection Test Unit''
|-
! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0
|-
| P1 , P2 || || P || S || X || E || R/W || A || Code opération
|-
| Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération
|}
Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire.
Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires.
Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles.
Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour.
====Le ''Hardware task switching'' des CPU x86====
Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres.
Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires.
Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé.
Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''.
Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante :
* Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé.
* Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur.
En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur.
[[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]]
===La segmentation sur les processeurs Burrough B5000 et plus===
Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir.
Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough.
La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT.
La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire.
La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques.
Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé.
: L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème.
[[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]]
==Les architectures à capacités==
Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014.
===Le partage de la mémoire sur les architectures à capacités===
Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants.
[[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]]
À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise.
Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution.
===Les capacités sont des pointeurs protégés===
Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment.
Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée.
[[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]]
Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu.
La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau).
La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau.
Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas.
Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé.
====La liste des capacités====
Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle.
[[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]]
La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments.
Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc.
L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante.
====Les capacités dispersées, les architectures taguées====
Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés.
Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée.
[[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]]
L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué.
===Les registres de capacité===
Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres.
Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''.
Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre.
Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit.
===Le recyclage de mémoire matériel===
Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée.
Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons.
Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles.
Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''.
Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur.
===L'intel iAPX 432===
Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080.
La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais.
====Les segments prédéfinis de l'Intel iAPX 432====
L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire !
Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment.
Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation :
* Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''.
* Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données.
* Des segments de domaine, pour les modules ou bibliothèques dynamiques.
* Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte.
* Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages.
* Et bien d'autres encores.
Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc.
L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non.
====Le support de la segmentation sur l'Intel iAPX 432====
La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''.
Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle.
Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments.
====Le jeu d'instruction de l'Intel iAPX 432====
L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc.
Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non.
le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales.
Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière.
* Le premier est l'opcode de l'instruction.
* Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé.
* Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile.
* Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles.
[[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]]
====Le support de l'orienté objet sur l'Intel iAPX 432====
L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture.
L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter.
Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager.
===Conclusion===
Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) :
* [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems].
Voici un document qui décrit le fonctionnement de l'Intel iAPX432 :
* [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ]
==La pagination==
Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique.
L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0.
[[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]]
Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86.
Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici.
===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires===
Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages''
[[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]]
De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur.
====Les défauts de page====
Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison.
Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci.
Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse.
Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver.
Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique.
Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation.
====Le remplacement des pages====
Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans.
Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''.
Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde.
Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''.
====Les algorithmes de remplacement des pages pris en charge par l'OS====
Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants.
Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes.
L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général.
L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter.
L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude.
Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1.
Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique.
Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive.
===La protection mémoire avec la pagination===
Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page.
Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS.
Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit.
Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter.
===La traduction d'adresse avec la pagination===
Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''.
Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible.
Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique.
[[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]]
====Les tables des pages simples====
Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder.
[[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]]
La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique.
[[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]]
====Les tables des pages inversées====
Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable.
Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle.
Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC.
[[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]]
====Les tables des pages multiples par espace d'adressage====
Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses.
Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée.
L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau.
L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale.
[[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]]
On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante.
====L'exemple des processeurs x86====
Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus.
Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille).
[[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]]
Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets).
[[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]]
La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant.
La table des pages gardait 2 niveaux pour les pages larges en PAE.
[[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]]
Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois.
[[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]]
En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés.
[[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]]
===La TLB et les caches associés===
En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages.
[[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]]
Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut.
Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers).
===L'abstraction matérielle des processus : une table des pages par processus===
[[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]]
Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes.
La première solution n'utilise qu'une seule table des pages. Pour cela, chaque entrée indique à quel processus elle est associée. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. Pour cela, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus, l'identifiant de processus est ignoré. Un défaut est que le partage d'une page entre plusieurs processus est impossible : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
L'intérêt de partager des pages entre tous les processus n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Emulée via relocation logicielle au démarrage du programme.
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
==Un cas particulier : la z/architecture d'IBM==
IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites.
La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum).
Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu".
===Le mode standard de la ''z/architecture''===
En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes.
: Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit.
Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage.
L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire".
===Les registres d'accès de la ''z/architecture''===
Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''.
Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7.
Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ?
Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait.
===L'utilisation des registres d'accès===
La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme".
La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même.
[[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]]
Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit.
Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant.
Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables.
{|class="wikitable"
|+ Registre d'accès
|-
! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN
|-
| 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée
|}
Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5.
: Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire.
[[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]]
Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée.
Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité.
* Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments.
* Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation.
* Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages.
* Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
90tmdda5n7fn2o7f5kdx5ow9y6sbok7
775247
775246
2026-10-13T21:58:03Z
Mewtow
31375
/* L'abstraction matérielle des processus : une table des pages par processus */
775247
wikitext
text/x-wiki
Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas.
Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''.
L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux.
==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires==
La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus.
: En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''.
A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments.
De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre.
La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi.
En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
[[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]]
Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions.
: Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier.
Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique.
===La mémoire virtuelle avec la relocation matérielle===
Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place.
Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments.
L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire.
Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle.
===L'extension d'adressage avec la relocation matérielle===
Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur.
L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16.
Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques.
==La segmentation en mode réel des processeurs x86==
Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque.
Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel.
===Les segments en mode réel===
[[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]]
La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts.
* Le segment '''''text''''', qui contient le code machine du programme, de taille fixe.
* Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc.
* Le segment pour la '''pile''', de taille variable.
* le reste est appelé le '''tas''', de taille variable.
Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation.
===Les registres de segments en mode réel===
Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme.
Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment.
Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS.
Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES.
Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES.
===La traduction d'adresse en mode réel===
La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir.
[[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]]
L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets.
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">0000 1000 0001 0010 0100</code>
| Adresse finale
| 20 bits
|}
Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs.
Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions.
Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant.
[[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]]
La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation.
[[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]]
===La segmentation en mode réel accepte plusieurs segments de code/données===
Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB.
Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi.
Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur.
Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas.
La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''.
===L'occupation de l'espace d'adressage par les segments===
Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste :
{| class="wikitable"
|-
! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés
|-
| Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement
|-
| Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement
|-
| Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far''
|-
| Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far''
|}
Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement.
[[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]]
Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents.
L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM.
Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment.
===Le mode réel sur les 286 et plus : la ligne d'adresse A20===
Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut :
{|class="wikitable"
|-
| <code> </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code>
| Registre de segment -
| 16 bits, décalé de 4 bits vers la gauche
|-
| <code>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code>
| Adresse finale
| 20 bits
|}
Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après.
Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM.
[[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]]
Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA.
{| class="wikitable"
|+ Espace d'adressage du 286
|-
! Adresses en héxadécimal !! Zone de mémoire
|-
| 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet
|-
| 10 0000 à 10 FFEF || ''High Memory Area''
|-
| 0 à 0F FFFF || Mémoire adressable en mode réel
|}
En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus.
Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs.
Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse.
: Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé.
Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086.
==La segmentation avec une table des segments==
La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée.
===Pourquoi plusieurs segments par programme ?===
L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction.
[[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]]
L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle.
Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile.
L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''.
La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment.
Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme.
===Les tables de segments avec la segmentation===
La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment.
Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme.
Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''.
Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire.
Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales.
Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales.
===La relocation avec la segmentation===
La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire.
La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau.
[[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]]
Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM.
Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations.
[[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]]
L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire.
Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale.
[[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]]
Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat.
[[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]]
Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement.
Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation.
Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''.
Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation.
Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''.
L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur.
Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits.
Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits.
===La mémoire virtuelle avec la segmentation===
La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation.
Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés.
Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules.
L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments.
L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS.
===Le partage de segments===
Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple.
La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts.
Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique.
Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque.
[[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]]
Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts.
Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité.
===L'extension d'adresse avec la segmentation===
L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme.
Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage.
===Le mode protégé des processeurs x86===
L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM.
Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286.
Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs.
Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale.
Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle.
Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient :
* le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ;
* deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ;
* un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données.
* un champ Type qui contient les bits suivants :
** un bit E qui indique si le segment contient du code exécutable ou non ;
** le bit RW qui indique s'il est en lecture seule ou non ;;
** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS;
** un bit DC assez spécifiques.
En haut à gauche, en bleu, on trouve deux bits :
* Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination.
* Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits.
[[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]]
Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit :
* 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ;
* un bit qui précise s'il faut accéder à la table des segments globale ou locale ;
* deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur).
[[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]]
En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale.
====La MMU du 386/486 : cache de segment, protection mémoire====
La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286.
L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière.
Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante.
Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite.
[[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]]
En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits.
Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache.
====L'implémentation de la protection mémoire sur le 386====
La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait.
Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode.
Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode.
{|class="wikitable"
|+ Entrée de la ''Protection Test Unit''
|-
! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0
|-
| P1 , P2 || || P || S || X || E || R/W || A || Code opération
|-
| Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération
|}
Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire.
Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires.
Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles.
Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour.
====Le ''Hardware task switching'' des CPU x86====
Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres.
Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires.
Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé.
Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''.
Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante :
* Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé.
* Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur.
En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur.
[[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]]
===La segmentation sur les processeurs Burrough B5000 et plus===
Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir.
Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough.
La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT.
La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire.
La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques.
Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé.
: L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème.
[[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]]
==Les architectures à capacités==
Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014.
===Le partage de la mémoire sur les architectures à capacités===
Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants.
[[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]]
À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise.
Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution.
===Les capacités sont des pointeurs protégés===
Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment.
Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée.
[[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]]
Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu.
La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau).
La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau.
Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas.
Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé.
====La liste des capacités====
Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle.
[[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]]
La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments.
Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc.
L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante.
====Les capacités dispersées, les architectures taguées====
Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés.
Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée.
[[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]]
L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué.
===Les registres de capacité===
Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres.
Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''.
Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre.
Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit.
===Le recyclage de mémoire matériel===
Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée.
Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons.
Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles.
Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''.
Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur.
===L'intel iAPX 432===
Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080.
La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais.
====Les segments prédéfinis de l'Intel iAPX 432====
L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire !
Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment.
Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation :
* Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''.
* Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données.
* Des segments de domaine, pour les modules ou bibliothèques dynamiques.
* Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte.
* Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages.
* Et bien d'autres encores.
Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc.
L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non.
====Le support de la segmentation sur l'Intel iAPX 432====
La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''.
Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle.
Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments.
====Le jeu d'instruction de l'Intel iAPX 432====
L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc.
Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non.
le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales.
Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière.
* Le premier est l'opcode de l'instruction.
* Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé.
* Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile.
* Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles.
[[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]]
====Le support de l'orienté objet sur l'Intel iAPX 432====
L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture.
L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter.
Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager.
===Conclusion===
Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) :
* [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems].
Voici un document qui décrit le fonctionnement de l'Intel iAPX432 :
* [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ]
==La pagination==
Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique.
L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0.
[[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]]
Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86.
Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici.
===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires===
Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages''
[[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]]
De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur.
====Les défauts de page====
Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison.
Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci.
Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse.
Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver.
Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique.
Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation.
====Le remplacement des pages====
Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans.
Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''.
Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde.
Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''.
====Les algorithmes de remplacement des pages pris en charge par l'OS====
Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants.
Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes.
L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général.
L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter.
L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude.
Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1.
Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique.
Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive.
===La protection mémoire avec la pagination===
Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page.
Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS.
Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit.
Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter.
===La traduction d'adresse avec la pagination===
Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''.
Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible.
Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique.
[[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]]
====Les tables des pages simples====
Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder.
[[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]]
La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique.
[[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]]
====Les tables des pages inversées====
Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable.
Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle.
Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC.
[[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]]
====Les tables des pages multiples par espace d'adressage====
Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses.
Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée.
L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau.
L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale.
[[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]]
On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante.
====L'exemple des processeurs x86====
Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus.
Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille).
[[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]]
Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets).
[[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]]
La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant.
La table des pages gardait 2 niveaux pour les pages larges en PAE.
[[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]]
Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois.
[[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]]
En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés.
[[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]]
===La TLB et les caches associés===
En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages.
[[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]]
Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut.
Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers).
===L'abstraction matérielle des processus===
[[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]]
Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes.
La première solution n'utilise qu'une seule table des pages. Pour cela, chaque entrée indique à quel processus elle est associée. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. Pour cela, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus, l'identifiant de processus est ignoré. Un défaut est que le partage d'une page entre plusieurs processus est impossible : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
L'intérêt de partager des pages entre tous les processus n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Emulée via relocation logicielle au démarrage du programme.
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
==Un cas particulier : la z/architecture d'IBM==
IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites.
La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum).
Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu".
===Le mode standard de la ''z/architecture''===
En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes.
: Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit.
Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage.
L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire".
===Les registres d'accès de la ''z/architecture''===
Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''.
Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7.
Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ?
Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait.
===L'utilisation des registres d'accès===
La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme".
La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même.
[[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]]
Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit.
Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant.
Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables.
{|class="wikitable"
|+ Registre d'accès
|-
! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN
|-
| 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée
|}
Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5.
: Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire.
[[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]]
Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée.
Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité.
* Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments.
* Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation.
* Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages.
* Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
9mj2j8a472sbqy7c7k27cdj38kx4r9p
Fonctionnement d'un ordinateur/L'émission des instructions
0
65843
775152
775105
2026-10-13T14:22:23Z
Mewtow
31375
/* Pipeliner les unités de calcul */
775152
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair dans quelques chapitres. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===Les pipelines entiers et flottants===
Un processeur a souvent deux avals séparés : un pour les opérations flottantes, et un autre pour le reste. Le reste en question regroupe à la fois les calculs entiers, les tests/branchements, les accès mémoire, et éventuellement d'autres instructions. Les deux avals sont appelés, par abus de langage, le '''pipeline flottant''' et le '''pipeline entier'''. Le pipeline flottant exécute les instructions flottantes, à l'exception des lectures/écritures flottantes, qui sont prises en charge par le pipeline entier. Il peut aussi exécuter les multiplications entières si elles sont prises en charge par le multiplieur flottant.
[[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]]
Un détail important est que les deux avals n'ont pas forcément le même nombre d'étages. Le pipeline flottant a souvent des étages en plus,car la FPU met plusieurs cycles pour faire son travail. A l'opposé, les instructions entières se font plus rapidement, souvent en un cycle pour les opérations simples. Un exemple est le pipeline du MIPS R4000. Son pipeline entier faisait 8 étages, le pipeline flottant avait 2 à 6 cycles en plus. Les étages de chargement et décodage étaient partagé, mais les deux avals avaient des nombres d'étages différents.
{|class="wikitable"
|-
! !! colspan="2" | Instruction fetch !! Decocage !! colspan="6" | Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
! Pipeline entier
| rowspan="2" | ''Fetch'' 1 || rowspan="2" | ''Fetch'' 2 || rowspan="2" | ''Decode'' + ''Read Operands'' || colspan="6" | ''Exec'' || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Tag Check'' || rowspan="2" | ''Writeback''
|-
! Pipeline flottant
| Exec || Exec || Exec || Exec || Exec || Exec
|}
Les échanges de données entre les deux pipelines se font avec copies entre registres spécialisées, qui copient un registre entier vers un registre flottant, et inversement. En clair, des instructions spécialisées qui gèrent la situation au mieux. Elles sont exécutées dans le pipeline entier. Lorsque le processeur décode une de ces instructions, il attend que le pipeline flottant ait terminé son travail, avant de poursuivre l'exécution.
: Sur le processeur alpha 61064, il n'y avait même pas de système de copie entre registres entier et flottants. Les échanges en question devaient passer par la mémoire RAM.
Les deux pipelines peuvent être totalement indépendants, ou au contraire fortement couplés. Il arrive que les deux pipelines ne peuvent pas fonctionner en parallèle. Quand l'un exécute des instructions, l'autre ne fait rien. Si une instruction est en cours de traitement dans la FPU, le pipeline entier cesse toute activité, il n’exécute pas d'instructions. Et inversement, quand le pipeline entier travaille, la FPU est en stand-by, elle n’exécute pas d'instructions flottantes.
Une autre méthode autorise le pipeline flottant et le pipeline entier à travailler en parallèle. Pendant que la FPU exécute des opérations flottantes, le pipeline entier peut exécuter des instructions. Cependant, faites attention : les deux pipelines démarrent une instruction par cycle. Le truc est que les instructions flottantes sont des instructions multi-cycles. Pendant qu'une instruction flottante s'exécute dans la FPU, le processeur peut démarrer une nouvelle instruction entière dans le pipeline flottant à chaque cycle. Les deux pipelines agissant sur des registres différents, cela ne pose pas de problème.
Il est possible que le pipeline entier ait des exceptions précises, alors que le pipeline flottant n'en ait pas. Les exceptions flottantes sont en effet bien séparées des exceptions entières. Et les deux peuvent être traitées séparément. D'anciens processeurs faisaient comme cela, comme le SuperSPARC de Texas Instrument.
Il est arrivé que le pipeline flottant soit traité comme un '''co-processeur intégré''' au processeur principal. Il y a deux grosses différences avec un aval flottant séparé. La première est qu'il y a une unité mémoire séparée dans le pipeline flottant. Le processeur peut alors faire lectures et écritures flottantes dans le pipeline flottant. La seconde est que l'unité flottante a une unité de contrôle dédiée, avec son propre décodeur et sa propre unité d'émission.
Il y a donc deux décodeurs : un décodeur principal, et le ''décodeur flottant''. Le décodeur principal décode toutes les instructions, sauf les instructions flottantes. Quand il détecte une instruction flottante, il l'envoie à l'unité de contrôle flottante, et c'est elle qui se charge du décodage. Et après ce décodeur flottant, il y a une ''unité d'émission flottante''. Elle s'occupe de répartir les instructions dans la bonne unité flottante, elle vérifie les dépendances structurelles, bref : c'est une unité d'émission classique, qui ne s'occupe que des FPUs.
[[File:Pipeline flottant fortement découplé.png|centre|vignette|upright=1.5|Pipeline flottant fortement découplé.]]
Quelques processeurs poussent le découplage au point d'ajouter une file d'instruction en aval du décodeur flottant. Le MIPS R8000 était de ceux là. Les instructions provenant du décodeur principal sont accumulées dans cette file d'instruction, ce qui fait que le pipeline flottant a quelques cycles de retard sur le pipeline entier, retarde qui est de plus variable. Mais un tel retard ne pose pas de problèmes en général, car les registres entiers et flottants sont séparés. Il n'y a synchronisation que quand les deux pipelines doivent échanger des données.
Pour résumer, il y a deux pipelines, entiers et flottants, qui travaillent en parallèle, voire chacun à leur rythme avec des files d'instruction différentes. Il s'agit d'une forme limitée d''''exécution dans le désordre''', cette dernière étant une optimisation qui permet d'exécuter des instructions dans un ordre différent de celui du programme exécuté. Elle permet de gagner en performance, mais causent des problèmes pour les exceptions précises. Par exemple, le MIPS R8000 avait un pipeline flottant totalement désynchronisé avec le pipeline entier, mais il utilisait des exceptions imprécises.
Il est aussi possible de charger et exécuter deux instructions en même temps, la première allant dans le pipeline entier, la seconde dans le pipeline flottant. Il s'agit d'une forme limitée de '''superscalarité''', une optimisation dont le principe est de charger, décoder, émettre et exécuter plusieurs instructions en même temps. Ici, une instruction entière et une flottante. Les processeurs dits superscalaires vont cependant plus loin que ça dans le cas général, il ne s'agit ici que d'une forme limitée.
===L'exécution parallèle avec des instructions multi-cycles===
Le pipeline entier peut lui aussi gérer des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles entières simplement. Il suffit d'ajouter un multiplieur entier, un ''barrel shifter'', éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite. Et cela permet des optimisations similaires à celle que permettent la séparation du pipeline entier et flottant.
L’avantage principal est que le pipeline entier ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''. En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme.
Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Cependant, il faut tenir compte d'un léger problème : une instructions multi-cycles occupe une voie pendant plusieurs cycles. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Ici, nous allons nous intéresser du cas où une instructions veut utiliser un aval déjà occupé par une instruction multicycle.
Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. En clair, deux instructions multi-cycles consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs. Sauf à pipeliner l'unité de calcul, mais nous mettons cette option de côté pour le moment.
===Le blocage du pipeline===
Si une micro-opération veut utiliser une unité occupée, il y a une ''dépendance structurelle''. La micro-opération est alors mise en attente, elle attend sagement dans l'unité d'émission que l'unité voulue soit libre. Mais si elle reste dans l'unité d'émission, elle bloque tous les étages précédents, les instructions précédentes ne peuvent pas avancer. Le pipeline est bloqué. Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer les dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Il est possible d'émettre une multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif.
La seconde solution pipeline/échelonne les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit découpé en cinq étages ? Découper une unité de multiplication est assez simple, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge, éliminant ainsi toute dépendance structurelle. Et mine de rien, c'est la solution qui est retenue pour les multiplieurs entier et flottants. L'additionneur flottant est lui aussi pipeliné.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Pipeliner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions s’échelonnent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
dlxvo14my6k7msda4u8jj5ogsn1sy4e
775153
775152
2026-10-13T14:22:45Z
Mewtow
31375
/* Pipeliner les unités de calcul */
775153
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair dans quelques chapitres. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===Les pipelines entiers et flottants===
Un processeur a souvent deux avals séparés : un pour les opérations flottantes, et un autre pour le reste. Le reste en question regroupe à la fois les calculs entiers, les tests/branchements, les accès mémoire, et éventuellement d'autres instructions. Les deux avals sont appelés, par abus de langage, le '''pipeline flottant''' et le '''pipeline entier'''. Le pipeline flottant exécute les instructions flottantes, à l'exception des lectures/écritures flottantes, qui sont prises en charge par le pipeline entier. Il peut aussi exécuter les multiplications entières si elles sont prises en charge par le multiplieur flottant.
[[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]]
Un détail important est que les deux avals n'ont pas forcément le même nombre d'étages. Le pipeline flottant a souvent des étages en plus,car la FPU met plusieurs cycles pour faire son travail. A l'opposé, les instructions entières se font plus rapidement, souvent en un cycle pour les opérations simples. Un exemple est le pipeline du MIPS R4000. Son pipeline entier faisait 8 étages, le pipeline flottant avait 2 à 6 cycles en plus. Les étages de chargement et décodage étaient partagé, mais les deux avals avaient des nombres d'étages différents.
{|class="wikitable"
|-
! !! colspan="2" | Instruction fetch !! Decocage !! colspan="6" | Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
! Pipeline entier
| rowspan="2" | ''Fetch'' 1 || rowspan="2" | ''Fetch'' 2 || rowspan="2" | ''Decode'' + ''Read Operands'' || colspan="6" | ''Exec'' || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Tag Check'' || rowspan="2" | ''Writeback''
|-
! Pipeline flottant
| Exec || Exec || Exec || Exec || Exec || Exec
|}
Les échanges de données entre les deux pipelines se font avec copies entre registres spécialisées, qui copient un registre entier vers un registre flottant, et inversement. En clair, des instructions spécialisées qui gèrent la situation au mieux. Elles sont exécutées dans le pipeline entier. Lorsque le processeur décode une de ces instructions, il attend que le pipeline flottant ait terminé son travail, avant de poursuivre l'exécution.
: Sur le processeur alpha 61064, il n'y avait même pas de système de copie entre registres entier et flottants. Les échanges en question devaient passer par la mémoire RAM.
Les deux pipelines peuvent être totalement indépendants, ou au contraire fortement couplés. Il arrive que les deux pipelines ne peuvent pas fonctionner en parallèle. Quand l'un exécute des instructions, l'autre ne fait rien. Si une instruction est en cours de traitement dans la FPU, le pipeline entier cesse toute activité, il n’exécute pas d'instructions. Et inversement, quand le pipeline entier travaille, la FPU est en stand-by, elle n’exécute pas d'instructions flottantes.
Une autre méthode autorise le pipeline flottant et le pipeline entier à travailler en parallèle. Pendant que la FPU exécute des opérations flottantes, le pipeline entier peut exécuter des instructions. Cependant, faites attention : les deux pipelines démarrent une instruction par cycle. Le truc est que les instructions flottantes sont des instructions multi-cycles. Pendant qu'une instruction flottante s'exécute dans la FPU, le processeur peut démarrer une nouvelle instruction entière dans le pipeline flottant à chaque cycle. Les deux pipelines agissant sur des registres différents, cela ne pose pas de problème.
Il est possible que le pipeline entier ait des exceptions précises, alors que le pipeline flottant n'en ait pas. Les exceptions flottantes sont en effet bien séparées des exceptions entières. Et les deux peuvent être traitées séparément. D'anciens processeurs faisaient comme cela, comme le SuperSPARC de Texas Instrument.
Il est arrivé que le pipeline flottant soit traité comme un '''co-processeur intégré''' au processeur principal. Il y a deux grosses différences avec un aval flottant séparé. La première est qu'il y a une unité mémoire séparée dans le pipeline flottant. Le processeur peut alors faire lectures et écritures flottantes dans le pipeline flottant. La seconde est que l'unité flottante a une unité de contrôle dédiée, avec son propre décodeur et sa propre unité d'émission.
Il y a donc deux décodeurs : un décodeur principal, et le ''décodeur flottant''. Le décodeur principal décode toutes les instructions, sauf les instructions flottantes. Quand il détecte une instruction flottante, il l'envoie à l'unité de contrôle flottante, et c'est elle qui se charge du décodage. Et après ce décodeur flottant, il y a une ''unité d'émission flottante''. Elle s'occupe de répartir les instructions dans la bonne unité flottante, elle vérifie les dépendances structurelles, bref : c'est une unité d'émission classique, qui ne s'occupe que des FPUs.
[[File:Pipeline flottant fortement découplé.png|centre|vignette|upright=1.5|Pipeline flottant fortement découplé.]]
Quelques processeurs poussent le découplage au point d'ajouter une file d'instruction en aval du décodeur flottant. Le MIPS R8000 était de ceux là. Les instructions provenant du décodeur principal sont accumulées dans cette file d'instruction, ce qui fait que le pipeline flottant a quelques cycles de retard sur le pipeline entier, retarde qui est de plus variable. Mais un tel retard ne pose pas de problèmes en général, car les registres entiers et flottants sont séparés. Il n'y a synchronisation que quand les deux pipelines doivent échanger des données.
Pour résumer, il y a deux pipelines, entiers et flottants, qui travaillent en parallèle, voire chacun à leur rythme avec des files d'instruction différentes. Il s'agit d'une forme limitée d''''exécution dans le désordre''', cette dernière étant une optimisation qui permet d'exécuter des instructions dans un ordre différent de celui du programme exécuté. Elle permet de gagner en performance, mais causent des problèmes pour les exceptions précises. Par exemple, le MIPS R8000 avait un pipeline flottant totalement désynchronisé avec le pipeline entier, mais il utilisait des exceptions imprécises.
Il est aussi possible de charger et exécuter deux instructions en même temps, la première allant dans le pipeline entier, la seconde dans le pipeline flottant. Il s'agit d'une forme limitée de '''superscalarité''', une optimisation dont le principe est de charger, décoder, émettre et exécuter plusieurs instructions en même temps. Ici, une instruction entière et une flottante. Les processeurs dits superscalaires vont cependant plus loin que ça dans le cas général, il ne s'agit ici que d'une forme limitée.
===L'exécution parallèle avec des instructions multi-cycles===
Le pipeline entier peut lui aussi gérer des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles entières simplement. Il suffit d'ajouter un multiplieur entier, un ''barrel shifter'', éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite. Et cela permet des optimisations similaires à celle que permettent la séparation du pipeline entier et flottant.
L’avantage principal est que le pipeline entier ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''. En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme.
Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Cependant, il faut tenir compte d'un léger problème : une instructions multi-cycles occupe une voie pendant plusieurs cycles. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Ici, nous allons nous intéresser du cas où une instructions veut utiliser un aval déjà occupé par une instruction multicycle.
Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. En clair, deux instructions multi-cycles consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs. Sauf à pipeliner l'unité de calcul, mais nous mettons cette option de côté pour le moment.
===Le blocage du pipeline===
Si une micro-opération veut utiliser une unité occupée, il y a une ''dépendance structurelle''. La micro-opération est alors mise en attente, elle attend sagement dans l'unité d'émission que l'unité voulue soit libre. Mais si elle reste dans l'unité d'émission, elle bloque tous les étages précédents, les instructions précédentes ne peuvent pas avancer. Le pipeline est bloqué. Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer les dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Il est possible d'émettre une multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif.
La seconde solution pipeline les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit découpé en cinq étages ? Découper une unité de multiplication est assez simple, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge, éliminant ainsi toute dépendance structurelle. Et mine de rien, c'est la solution qui est retenue pour les multiplieurs entier et flottants. L'additionneur flottant est lui aussi pipeliné.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Pipeliner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions se pipelinent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
oh5z31983kyxhvd8761lgm5jc4rt3l0
775239
775153
2026-10-13T20:44:36Z
Mewtow
31375
775239
wikitext
text/x-wiki
Un processeur avec un pipeline est décomposé en deux parties. La première partie, l’'''amont''' (''front end''), prend en charge les étages communs à toutes les instructions. Il correspond au séquenceur et à l'unité de chargement et regroupe la mise à jour du ''program counter'', le chargement, l'étage de décodage, etc. L'amont est suivi par le chemin de données, qui est organisée en plusieurs voies, chacune formant ce qu'on appelle un '''aval''' (''back end''), ou encore une '''voie'''.
[[File:Pipeline avec un aval et un amont (back-end et front-end).png|centre|vignette|upright=1.5|Pipeline avec plusieurs avals et un amont (back-end et front-end).]]
Un aval correspond soit à une unité de calcul, soit à l'unité mémoire, soit à une unité pour les branchements. Par exemple, il est fréquent d'avoir des voies séparés pour l'addition, la multiplication et les décalages (''barrel shifter''), vu que ces opérations sont dans des ALU entières séparées. De même, on a une voie séparée pour les instructions flottantes, lié au fait que la FPU est une unité de calcul séparée. Toutes les voies partagent le banc de registre, que ce soit pour lire les opérandes ou enregistrer un résultat.
{|
|[[File:Pipeline à deux aval de type load-store.png|vignette|upright=1.5|Pipeline à trois avals.]]
|[[File:Pipeline avec un aval pour les instructions arithmétiques, un aval pour les accès mémoire et un aval pour les branchements.png|vignette|upright=1.5|Pipeline avec trois avals, bis.]]
|}
Et cette structuration en amont et avals est à l'origine de nombreuses optimisations. Nous verrons dans quelques chapitres que l'on peut découpler les deux, avec une file de µops. Mais n'allons pas trop vite en besogne. Pour le moment, nous allons juste parler de la manière dont sont réparties les instructions sur plusieurs avals.
==L'émission des instructions et l'unité d'émission==
Le processeur envoie des instructions décodées dans la voie appropriée. Par exemple, une instruction d'accès mémoire va dans l'unité mémoire, dans la voie spécialisée dans les accès mémoire. Une instruction de calcul va dans la voie spécialisée dans les calculs entiers simples, à savoir l'ALU entière. Et ainsi de suite. L'envoi d'une instruction/micro-opération dans une voie s'appelle l''''émission'''. On dit qu'une instruction est émise quand elle est envoyée dans une voie.
Le terme "émission" reviendra souvent dans la suite du cours. On dira qu'une instruction est émise si elle est envoyée au chemin de donnée, qu'elle quitte l'unité d'émission. Une instruction peut être émise si elle n'a pas de dépendance bloquante, qu'elle peut s'exécuter. Elle rentre alors dans le chemin de donnée, lit ses opérandes et s'exécute. L'unité d'émission est donc la toute fin de l'unité de contrôle, du séquenceur, c'est le point d'entrée du chemin de données.
Le processeur contient une '''unité d'émission''', en sortie du décodeur. Elle décide dans quelle voie envoyer telle ou telle micro-opération, mais pas seulement. Son rôle deviendra nettement plus clair dans quelques chapitres. Je l'introduis précocement histoire de simplifier les explications qui suivent. Pour le moment, il est juste évident qu'elle sert de ''répartiteur'', pour envoyer la micro-opération dans la bonne unité de calcul, le bon aval, la bonne voie. Mais nous verrons que c'est cette unité qui garantit que les instructions se terminent dans l'ordre.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
===Les pipelines entiers et flottants===
Un processeur a souvent deux avals séparés : un pour les opérations flottantes, et un autre pour le reste. Le reste en question regroupe à la fois les calculs entiers, les tests/branchements, les accès mémoire, et éventuellement d'autres instructions. Les deux avals sont appelés, par abus de langage, le '''pipeline flottant''' et le '''pipeline entier'''. Le pipeline flottant exécute les instructions flottantes, à l'exception des lectures/écritures flottantes, qui sont prises en charge par le pipeline entier. Il peut aussi exécuter les multiplications entières si elles sont prises en charge par le multiplieur flottant.
[[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]]
Un détail important est que les deux avals n'ont pas forcément le même nombre d'étages. Le pipeline flottant a souvent des étages en plus,car la FPU met plusieurs cycles pour faire son travail. A l'opposé, les instructions entières se font plus rapidement, souvent en un cycle pour les opérations simples. Un exemple est le pipeline du MIPS R4000. Son pipeline entier faisait 8 étages, le pipeline flottant avait 2 à 6 cycles en plus. Les étages de chargement et décodage étaient partagé, mais les deux avals avaient des nombres d'étages différents.
{|class="wikitable"
|-
! !! colspan="2" | Instruction fetch !! Decocage !! colspan="6" | Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
! Pipeline entier
| rowspan="2" | ''Fetch'' 1 || rowspan="2" | ''Fetch'' 2 || rowspan="2" | ''Decode'' + ''Read Operands'' || colspan="6" | ''Exec'' || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Data Fetch'' 1 || rowspan="2" | ''Tag Check'' || rowspan="2" | ''Writeback''
|-
! Pipeline flottant
| Exec || Exec || Exec || Exec || Exec || Exec
|}
Les échanges de données entre les deux pipelines se font avec copies entre registres spécialisées, qui copient un registre entier vers un registre flottant, et inversement. En clair, des instructions spécialisées qui gèrent la situation au mieux. Elles sont exécutées dans le pipeline entier. Lorsque le processeur décode une de ces instructions, il attend que le pipeline flottant ait terminé son travail, avant de poursuivre l'exécution.
: Sur le processeur alpha 61064, il n'y avait même pas de système de copie entre registres entier et flottants. Les échanges en question devaient passer par la mémoire RAM.
Les deux pipelines peuvent être totalement indépendants, ou au contraire fortement couplés. Il arrive que les deux pipelines ne peuvent pas fonctionner en parallèle. Quand l'un exécute des instructions, l'autre ne fait rien. Si une instruction est en cours de traitement dans la FPU, le pipeline entier cesse toute activité, il n’exécute pas d'instructions. Et inversement, quand le pipeline entier travaille, la FPU est en stand-by, elle n’exécute pas d'instructions flottantes.
Une autre méthode autorise le pipeline flottant et le pipeline entier à travailler en parallèle. Pendant que la FPU exécute des opérations flottantes, le pipeline entier peut exécuter des instructions. Cependant, faites attention : les deux pipelines démarrent une instruction par cycle. Le truc est que les instructions flottantes sont des instructions multi-cycles. Pendant qu'une instruction flottante s'exécute dans la FPU, le processeur peut démarrer une nouvelle instruction entière dans le pipeline flottant à chaque cycle. Les deux pipelines agissant sur des registres différents, cela ne pose pas de problème.
Il est possible que le pipeline entier ait des exceptions précises, alors que le pipeline flottant n'en ait pas. Les exceptions flottantes sont en effet bien séparées des exceptions entières. Et les deux peuvent être traitées séparément. D'anciens processeurs faisaient comme cela, comme le SuperSPARC de Texas Instrument.
Il est arrivé que le pipeline flottant soit traité comme un '''co-processeur intégré''' au processeur principal. Il y a deux grosses différences avec un aval flottant séparé. La première est qu'il y a une unité mémoire séparée dans le pipeline flottant. Le processeur peut alors faire lectures et écritures flottantes dans le pipeline flottant. La seconde est que l'unité flottante a une unité de contrôle dédiée, avec son propre décodeur et sa propre unité d'émission.
Il y a donc deux décodeurs : un décodeur principal, et le ''décodeur flottant''. Le décodeur principal décode toutes les instructions, sauf les instructions flottantes. Quand il détecte une instruction flottante, il l'envoie à l'unité de contrôle flottante, et c'est elle qui se charge du décodage. Et après ce décodeur flottant, il y a une ''unité d'émission flottante''. Elle s'occupe de répartir les instructions dans la bonne unité flottante, elle vérifie les dépendances structurelles, bref : c'est une unité d'émission classique, qui ne s'occupe que des FPUs.
[[File:Pipeline flottant fortement découplé.png|centre|vignette|upright=1.5|Pipeline flottant fortement découplé.]]
Quelques processeurs poussent le découplage au point d'ajouter une file d'instruction en aval du décodeur flottant. Le MIPS R8000 était de ceux là. Les instructions provenant du décodeur principal sont accumulées dans cette file d'instruction, ce qui fait que le pipeline flottant a quelques cycles de retard sur le pipeline entier, retarde qui est de plus variable. Mais un tel retard ne pose pas de problèmes en général, car les registres entiers et flottants sont séparés. Il n'y a synchronisation que quand les deux pipelines doivent échanger des données.
Pour résumer, il y a deux pipelines, entiers et flottants, qui travaillent en parallèle, voire chacun à leur rythme avec des files d'instruction différentes. Il s'agit d'une forme limitée d''''exécution dans le désordre''', cette dernière étant une optimisation qui permet d'exécuter des instructions dans un ordre différent de celui du programme exécuté. Elle permet de gagner en performance, mais causent des problèmes pour les exceptions précises. Par exemple, le MIPS R8000 avait un pipeline flottant totalement désynchronisé avec le pipeline entier, mais il utilisait des exceptions imprécises.
Il est aussi possible de charger et exécuter deux instructions en même temps, la première allant dans le pipeline entier, la seconde dans le pipeline flottant. Il s'agit d'une forme limitée de '''superscalarité''', une optimisation dont le principe est de charger, décoder, émettre et exécuter plusieurs instructions en même temps. Ici, une instruction entière et une flottante. Les processeurs dits superscalaires vont cependant plus loin que ça dans le cas général, il ne s'agit ici que d'une forme limitée.
===L'exécution parallèle avec des instructions multi-cycles===
Le pipeline entier peut lui aussi gérer des '''instructions multi-cycles''', qui mettent plusieurs cycles d'horloge pour s'exécuter. En pratique, ces instructions sont les multiplications, divisions, et les instructions flottantes. Il arrive aussi que les décalages/rotations prennent deux cycles d'horloge, mais ce n'est pas la norme. Les processeurs sans instructions multi-cycles sont rares.
La présence de plusieurs avals permet de gérer les instructions multi-cycles entières simplement. Il suffit d'ajouter un multiplieur entier, un ''barrel shifter'', éventuellement un circuit diviseur. Ils ont tous leur voie réservée, séparée de la voie pour l'ALU entière proprement dite. Et cela permet des optimisations similaires à celle que permettent la séparation du pipeline entier et flottant.
L’avantage principal est que le pipeline entier ne se bloque pas pendant qu'on exécute une instruction multi-cycle. Le pipeline impose d'exécuter une nouvelle instruction par cycle, dans une unité de calcul. Si un multiplieur est occupé pendant plusieurs cycles d'horloge, on peut quand même lancer une addition par cycle dans l'ALU entière, ou un décalage dans le ''barrel shifter''. En clair, avec des instructions multi-cycles, le pipeline exploite plusieurs voies en même temps, c'est même la norme.
Par exemple, prenons un processeur avec une unité mémoire et une ALU entière, sans même d'ALU pour des opérations multicycle. Il est possible de faire des calculs dans l'ALU pendant que l'unité mémoire fait une lecture/écriture. La lecture est lancée à un cycle, les opérations de l'ALU sont lancées dans les cycles suivants. Une contrainte majeure est que l'addition et la lecture/écriture soient indépendants. Par exemple, l'addition ne doit pas avoir la donnée lue comme opérande. Si c'est le cas, on doit attendre que la donnée soit lue avant de lancer l'addition, sinon cela ne fonctionne pas.
Autre exemple, cette fois-ci avec des opérations multi-cycles. Prenons un processeur avec une ALU entière, un multiplieur entier et une unité mémoire. Le processeur peut émettre une micro-opération mémoire lors du cycle 1, puis une multiplication au cycle 2, et une addition au cycle 3. L'accès mémoire va occuper l'unité mémoire pendant plusieurs cycles, idem pour la multiplication. Lors du cycle 3, l'unité mémoire sera encore en train de faire un accès mémoire, le multiplieur sera en pleine multiplication, et l'ALU entière fera une opération entière.
Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes.
Pour donner un exemple réaliste, voici l'exemple des processeurs Xscale. Ils avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAC en parallèle d'une opération dans l'ALU entière ou d'une opération mémoire. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
==Les dépendances structurelles : l'occupation de chaque voie==
Un pipeline peut démarrer une nouvelle instruction par cycle, du moment qu'il a un aval disponible pour. Cependant, il faut tenir compte d'un léger problème : une instructions multi-cycles occupe une voie pendant plusieurs cycles. Et cela nous amène à parler des '''dépendances structurelles''', à savoir le cas où deux instructions veulent utiliser le même circuit en même temps. Ici, nous allons nous intéresser du cas où une instructions veut utiliser un aval déjà occupé par une instruction multicycle.
Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. En clair, deux instructions multi-cycles consécutives ne peuvent pas utiliser la même voie, la même ALU. La voie sera occupée par la première instruction, les autres devront attendre que la première ait terminé ses calculs. Sauf à pipeliner l'unité de calcul, mais nous mettons cette option de côté pour le moment.
===Le blocage du pipeline===
Si une micro-opération veut utiliser une unité occupée, il y a une ''dépendance structurelle''. La micro-opération est alors mise en attente, elle attend sagement dans l'unité d'émission que l'unité voulue soit libre. Mais si elle reste dans l'unité d'émission, elle bloque tous les étages précédents, les instructions précédentes ne peuvent pas avancer. Le pipeline est bloqué. Lorsque le pipeline est bloqué, les instructions cessent de progresser d'un étage à l'autre, elles restent dans l'étage courant. Le ''program counter'' est lui aussi gelé : il n'est plus incrémenté et maintient l'adresse en cours, tant que l'accès mémoire n'est pas terminé. Un blocage de pipeline de ce type s'appelle un '''''pipeline stall'''''. Nous parlerons de '''blocage de pipeline'''.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Bloquer le pipeline demande de deux faire plusieurs choses : bloquer la propagation des instructions dans le pipeline, décider quand bloquer le pipeline et pour combien de temps. Bloquer la propagation des instructions consiste à geler les registres de pipeline, ceux entre les étages. Quand ils sont gelés, les registres de pipeline n'acceptent pas ce qui vient de l'étage précédent, ils ne tiennent pas compte de ce qu'il y a sur leur entrée, ils conservent leur valeur pendant toute la durée du blocage. Le processeur génère un signal qui dit aux registres de pipeline dans quel mode fonctionner : soit propager normalement les instructions, soit se geler et rester sur place.
Il y a plusieurs méthodes pour geler les registres. La première ajoute une entrée ''Enable'', qui autorise les écritures dans le registre du pipeline. En temps normal, il est à 1, ce qui fait que les registres acceptent ce qui vient de l'étage précédent à chaque cycle. Mais lors d'un défaut de cache, il est mis à zéro. Le registre maintient son contenu, tant que l'entrée ''Enable'' est à 0, il ne tient pas compte de qui arrive de l'étage précédent. Les entrées ''Enable'' sont calculées à partir du signal ''pipeline stall'', avec quelques portes logiques. Une méthode alternative utilise le ''clock gating'' pour les étages qui doivent être gelés.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
Dans la suite du cours, nous verrons de nombreux cas dans lesquels bloquer le pipeline est une obligation si on veut que le processeur fonctionne correctement. Il s'agit là d'une fonctionnalité très importante de tous les pipelines, anciens comme modernes. Évidemment, bloquer le pipeline est une perte de performances : le pipeline ne fait rien tant qu'une instruction n'est pas terminée, ce qui va à l'encontre du principe du pipeline. Et de nombreuses techniques d'optimisations visent à réduire les blocages de pipeline. Mais laissons cela pour la suite du chapitre.
===La gestion de la disponibilité des ALUs===
L'étage d'émission détecte les dépendances structurelles au niveau de l'unité de calcul. Elle apparaissent quand deux instructions veulent exécuter une instruction en même temps, sur la même ALU. Elles impliquent généralement des instructions multicycles, qui occupent une unité de calcul pendant plusieurs cycles. Un exemple est celui où on veut faire deux multiplications à la suite, en supposant qu'une multiplication fasse 5 cycles : la seconde doit attendre que la première se termine car le multiplieur est occupé pendant ce temps. Face à une dépendance structurelle de ce type, l'unité d'émission met la seconde instruction en attente, tant que l'unité de calcul est occupée. L'unité d'émission bloque le pipeline si le processeur utilise l'émission dans l'ordre. Reste à détecter les conflits d'accès, les dépendances structurelles.
Une solution consiste à déterminer les paires d'instructions consécutives qui peuvent donner lieu à des dépendances structurelles. Par exemple, si une instruction de multiplication est dans le pipeline, je sais que je ne peux pas démarrer de nouvelle multiplication immédiatement après, s'il n'y a qu'un seul multiplieur. Détecter les dépendances structurelles consiste à comparer l'opcode de l'instruction à émettre, avec les opcodes des instructions en cours d’exécution dans le pipeline. Si jamais l'opcode de l'instruction à émettre et une opcode dans le pipeline forment une paire d'opérations interdite, il y a dépendance structurelle et on doit bloquer le pipeline.
Une autre solution mémorise un bit pour chaque ALU qui indique si elle est occupée. L'ensemble des bits est appelé le '''vecteur de disponibilité des ALUs'''.
{|class="wikitable"
|+ Vecteur de disponibilité des ALUs
|-
! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
| 0 || 0 || 1 || 0 || 0
|}
Avant d'émettre une instruction, l'unité d'émission vérifie si une unité de calcul adéquate est disponible. Pour cela, chaque instruction est associée à un masque qui précise quelles unité de calcul peuvent l'exécuter. Détecter une dépendance structurelle demande une simple opération de masquage entre ce masque et le vecteur de disponibilité des ALUs.
{|class="wikitable"
|+ Masque par instruction, associé au vecteur de disponibilité précédent
|-
! !! ADD1 BUSY !! ADD2 BUSY !! MUL BUSY !! COMP CUSY !! LOAD /STORE BUSY
|-
! ADD
| 1 || 1 || 0 || 0 || 0
|-
! MUL
| 0 || 0 || 1 || 0 || 0
|-
! COMP
| 0 || 0 || 0 || 1 || 0
|-
! LOAD
| 0 || 0 || 0 || 0 || 1
|-
! STORE
| 0 || 0 || 0 || 0 || 1
|-
|}
Toute la difficulté tient dans la mise à jour des bits de disponibilité des ALUs. Une solution assez simple intègre des compteurs dans le processeur pour chaque bit de disponibilité. La technique part du principe que la durée de chaque instruction est connue et qu'on peut l'utiliser pour initialiser un compteur. Lors de l'émission d'une instruction sur une ALU, le compteur associé à l'ALU est initialisé avec le nombre de cycles de l'instruction. Il est décrémenté à chaque cycle d'horloge. Une fois qu'il atteint 0, le bit de disponibilité est mis à 0.
Une autre solution délègue cette tâche à l'unité de calcul. Dès qu'un calcul se termine sur l'ALU, l'unité de calcul prévient qu'elle est libre avec un signal FREE qu'elle envoie à l'unité d'émission. L'avantage est que la solution prend en charge naturellement le cas où les opérations sont de durée variable. Un exemple est celui d'un additionneur à saut de retenue, où la durée d'une addition dépend de la propagation de la retenue. Un autre exemple est celui de certains multiplieurs itératifs (qui font la multiplication en effectuant une suite de décalages et d'addition), qui fournissent le résultat en avance pour certaines opérandes. Ou encore les circuits diviseurs par décalage/soustraction qui s’arrêtent dès que le dividende partiel tombe à zéro. Et d'ailleurs, on va voit que cette solution marche assez bien pour les instructions mémoire.
===Pipeliner les unités de calcul===
En théorie, il est possible d'éliminer les dépendances structurelles. Une solution simple duplique l'unité de calcul fautive. Prenons un exemple où c'est le circuit multiplieur qui est fautif, où la multiplication prend cinq cycles sur le processeur. Il est possible d'émettre une multiplication à chaque cycle si on dispose de 5 circuits multiplieurs : on lance une nouvelle multiplication à chaque cycle dans un multiplier différent. Mais le cout en transistors est prohibitif.
La seconde solution pipeline les unités de calcul. Si jamais vous avez une opération qui prend cinq cycles, pourquoi ne pas lui fournir un seul circuit découpé en cinq étages ? Découper une unité de multiplication est assez simple, par exemple. En faisant ainsi, il est possible de démarrer une nouvelle multiplication à chaque cycle d'horloge, éliminant ainsi toute dépendance structurelle. Et mine de rien, c'est la solution qui est retenue pour les multiplieurs entier et flottants. L'additionneur flottant est lui aussi pipeliné.
[[File:Exemple avec une unité de multiplication séparée, totalement échelonnée.png|centre|vignette|upright=2|Exemple avec une unité de multiplication séparée, totalement pipelinée.]]
Pipeliner un circuit permet un gain de performance pour un cout en circuit modéré : il suffit d'ajouter quelques registres dans le circuit. Le problème est que certaines instructions se pipelinent mal, notamment les divisions. Aussi, de nombreux concepteurs de processeurs laissent quelques dépendances structurelles. Nous verrons comment elles sont gérées au chapitre suivant.
==La fréquence des avals des pipelines multicycle==
L'usage d'un pipeline complexe , qui sépare amont et avals, permet de nombreuses optimisations. Par exemple, il est possible de faire fonctionner certains avals à une fréquence supérieure aux autres. Typiquement, on peut faire fonctionner l'unité de calcul flottante à une fréquence inférieure des unités de calcul entières, afin d'économiser un peu d'énergie. Ou encore, certaines ALU peuvent fonctionner à une fréquence double de celle du processeur, afin de gagner en performance. Voyons un petit peu quelles sont ces optimisations.
===Le ''clock gating'' des avals inutilisés===
Afin de réduire la consommation d'énergie du processeur, les avals inutilisés peuvent être désactivés, mis en veille. La technique du ''clock gating'' vue dans le chapitre sur la consommation électrique des circuits, coupe le signal d'horloge pour les unités de calcul inutilisées. Mais il est aussi possible de couper l'alimentation, ce qui porte le nom de ''power gating''.
Les processeurs avec un pipeline simple bloquent dès qu'un défaut de cache est rencontré, à savoir qu'ils émettent des bulles de pipeline tant que la RAM n'a pas répondu. Lors d'un défaut de cache, il est possible de désactiver les unités de calcul et les registres en attendant que la RAM réponde. Les registres sont réactivés juste avant que la donnée n'arrive. Les gains sont d'autant plus grands que les accès mémoires sont longs, mais il faut avouer que ce n'est pas l'exemple le plus crédible. Les processeurs modernes disposent d'optimisations, comme les lectures non-bloquantes ou l'exécution dans le désordre, qui permettent d'exécuter des calculs pendant un défaut de cache.
Un autre exemple, bien plus intéressant, se base sur une observation assez intéressante : il est très rare qu'un programme entrelace des instructions flottantes et entières. En conséquence, il est possible de désactiver la FPU et le banc de registres flottants si elles sont inutilisées. Et il est aussi possible de désactiver l'ALU entière et les registres entiers pendant les instructions de calcul flottant. Les instructions flottantes étant assez longues, généralement une dizaine de cycles, voire plus, désactiver l'ALU et les registres entiers permet d'économiser pas mal d'énergie. Et vu qu'une instruction flottante vient rarement seule, l'ALU et les registres entiers sont généralement désactivés pendant une centaine/milliers de cycles d'horloge.
La désactivation des unités inutilisée est commandée par l'unité de contrôle. Une fois qu'elle a décodée l'instruction, elle sait quelles unités sont nécessaires pour exécuter l'instruction et quelles sont celles inutilisées. L'unité de contrôle a juste à envoyer des signaux de commande supplémentaires aux circuits de ''clock gating''. Les gains peuvent être substantiels. Par exemple, pour le processeur Power 5, IBM a déclaré que le ''clock gating'' lui permettait d'économiser 25% d'énergie.
===Les unités de calcul à double fréquence du Pentium 4===
Le Pentium 4 était un peu particulier dans son genre, avec une ALU à mi-chemin entre une ALU normale et une ALU bit-slicée. Il disposait de plusieurs unités de calcul sur les nombres entiers, dont l'une d'entre elle était une ALU simple qui ne gérait que les additions, les soustractions, les opérations logiques et les comparaisons. Les multiplications et décalages étaient gérés par une ALU séparée. Il y avait donc une ALU simple à côté d'une ALU complexe.
L'ALU simple était composée de deux sous-ALU de 16 bits chacune. La première envoyait le bit de retenue qu'elle a calculée à la seconde. Un point important est que l'ALU prenait deux cycles d'horloge pour faire son travail : le premier cycle calculait les 16 bits de poids faible dans la première sous-ALU, puis calculait les 16 bits de poids fort lors du second cycle (il y avait aussi un troisième cycle pour le calcul des drapeaux du registre d'état, mais passons). Le tout est appelé '''addition étagée''' (''staggered add'') dans la documentation Intel.
Et la magie était que l'unité de calcul fonctionnait à une fréquence double de celle du processeur ! Pour faire la différence entre les deux fréquences, nous parlerons de fréquence/cycle processeur et de fréquence/cycle de l'ALU. Le résultat de ce fonctionnement franchement bizarre, est que les 16 bits de poids faible étaient calculés en une moitié de cycle processeur, alors que l'opération complète prenait un cycle. L'utilité devient évidente quand on sait que l'ALU simple était utilisée pour les calculs d'adresse. L'accès à la mémoire cache intégrée au processeur a besoin des bits de poids faible de l'adresse en priorité, les bits de poids fort étant nécessaires plus tard lors de l'accès. Calculer les bits de poids faibles d'une adresse en avance permettait d'accélérer les accès au cache de quelques cycles.
La technique en question porte le nom barbare d''''ALU ''double pumped''''', dont une traduction naïve ne donne pas un terme français très parlant. L'idéal est de la parler d'ALU à double fréquence. Il peut exister des ALU à triple ou quadruple fréquence, mais ce n'est pas très utilisé. Il faut noter que certains processeurs autres que le Pentium 4 utilisent cette technique, mais nous en reparlerons quand nous serons au chapitre sur les processeurs SIMD.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les optimisations du chargement des instructions
| prevText=Les optimisations du chargement des instructions
| next=Les pipelines in-order
| nextText=Les pipelines in-order
}}
</noinclude>
dlwwdyy23ol1doyjoc2tk9fh77sj7na
Fonctionnement d'un ordinateur/Le renommage de registres
0
65844
775188
774792
2026-10-13T15:31:55Z
Mewtow
31375
/* Les différentes implémentations du renommage de registres */
775188
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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 et le ''scheduler'' contiennent des numéros de registres virtuels, des pointeurs vers les registres. Il y a toujours un réseau de contournement pour connecter la sortie d'une ALU aux entrées des autres, qui gère le cas où la sortie de l'ALU est utilisé comme opérande au cycle suivant. Mais pour réutiliser un résultat deux cycles après ou plus tard, c'est le banc de registres physiques qui s'en charge. Les opérandes viennent donc soit du banc de registres physique, soit du réseau de contournement.
[[File:Renommage à banc de registres physiques.png|centre|vignette|upright=2|Renommage à banc de registres physiques.]]
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 tampon de réordonnancement. Les entrées du ROB se voient ajouter un champ pour mémoriser le résultat d'une instruction, et ce champ sert de registre virtuel. Les entrées du ROB deviennent ainsi adressables, elle ont un numéro, qui sert de numéro/nom de registre virtuel.
Avec cette technique, les registres architecturaux sont séparés des registres virtuels inclus dans le ROB. Les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file'''''. Le ''scheduler'' mémorise soit un nom de registre architectural, soit l'adresse de l'entrée du ROB qui contient le résultat voulu. Quand une instruction a tous ses opérandes de prêts, ceux-ci sont lus depuis le ROB ou les registres architecturaux. Pour cela, 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.
[[File:Renommage dans le tampon de réordonnancement.png|centre|vignette|upright=2|Renommage dans le tampon de réordonnancement.]]
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. 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 dernier défaut est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, on a vu plus haut que c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Le hardware ajouté pour les entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon.
===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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
8cnopg54wn9ow3mihrmseai20520lwu
775189
775188
2026-10-13T15:34:48Z
Mewtow
31375
/* Le renommage à banc de registres physiques */
775189
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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.
[[File:Renommage à banc de registres physiques.png|centre|vignette|upright=2|Renommage à banc de registres physiques.]]
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 tampon de réordonnancement. Les entrées du ROB se voient ajouter un champ pour mémoriser le résultat d'une instruction, et ce champ sert de registre virtuel. Les entrées du ROB deviennent ainsi adressables, elle ont un numéro, qui sert de numéro/nom de registre virtuel.
Avec cette technique, les registres architecturaux sont séparés des registres virtuels inclus dans le ROB. Les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file'''''. Le ''scheduler'' mémorise soit un nom de registre architectural, soit l'adresse de l'entrée du ROB qui contient le résultat voulu. Quand une instruction a tous ses opérandes de prêts, ceux-ci sont lus depuis le ROB ou les registres architecturaux. Pour cela, 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.
[[File:Renommage dans le tampon de réordonnancement.png|centre|vignette|upright=2|Renommage dans le tampon de réordonnancement.]]
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. 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 dernier défaut est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, on a vu plus haut que c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Le hardware ajouté pour les entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon.
===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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
qjbh94dhqc579nzsd2qwkfcsqnj8b9m
775190
775189
2026-10-13T15:35:17Z
Mewtow
31375
/* Le renommage à banc de registres physiques */
775190
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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.]]
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 tampon de réordonnancement. Les entrées du ROB se voient ajouter un champ pour mémoriser le résultat d'une instruction, et ce champ sert de registre virtuel. Les entrées du ROB deviennent ainsi adressables, elle ont un numéro, qui sert de numéro/nom de registre virtuel.
Avec cette technique, les registres architecturaux sont séparés des registres virtuels inclus dans le ROB. Les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file'''''. Le ''scheduler'' mémorise soit un nom de registre architectural, soit l'adresse de l'entrée du ROB qui contient le résultat voulu. Quand une instruction a tous ses opérandes de prêts, ceux-ci sont lus depuis le ROB ou les registres architecturaux. Pour cela, 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.
[[File:Renommage dans le tampon de réordonnancement.png|centre|vignette|upright=2|Renommage dans le tampon de réordonnancement.]]
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. 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 dernier défaut est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, on a vu plus haut que c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Le hardware ajouté pour les entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon.
===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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
s680ijio9svdyu0gc8ga6k5k6z3canc
775191
775190
2026-10-13T15:36:52Z
Mewtow
31375
/* Le renommage dans le tampon de réordonnancement */
775191
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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.]]
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. Les entrées du ROB se voient ajouter un champ pour mémoriser le résultat d'une instruction, et ce champ sert de registre virtuel. Les entrées du ROB deviennent ainsi adressables, elle ont un numéro, qui sert de numéro/nom de registre virtuel.
Avec cette technique, les registres architecturaux sont séparés des registres virtuels inclus dans le ROB. Les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file'''''. Le ''scheduler'' mémorise soit un nom de registre architectural, soit l'adresse de l'entrée du ROB qui contient le résultat voulu. Quand une instruction a tous ses opérandes de prêts, ceux-ci sont lus depuis le ROB ou les registres architecturaux. Pour cela, 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.
[[File:Renommage dans le tampon de réordonnancement.png|centre|vignette|upright=2|Renommage dans le tampon de réordonnancement.]]
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. 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 dernier défaut est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, on a vu plus haut que c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Le hardware ajouté pour les entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon.
===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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
3cdcc5nd5aumnu7twhlfyowzvp5tmf7
775192
775191
2026-10-13T15:40:01Z
Mewtow
31375
/* Le renommage dans le tampon de réordonnancement */
775192
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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.]]
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 séparés des registres virtuels inclus dans le ROB. Les registres architecturaux sont regroupés dans un banc de registres spécialisé, appelé le '''''retirement register file'''''.
[[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. Les entrées du ROB deviennent 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. 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 dernier défaut est que le nombre de registres virtuel est égal au nombre d'entrées dans le ROB. Or, on a vu plus haut que c'est un peu du gâchis, car beaucoup d'instructions ne produisent aucun résultat. Le hardware ajouté pour les entrées est donc sous-utilisé, il ne sert que si l'instruction fournit un résultat, mais ne sert à rien sinon.
===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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
t1e52qrhv5shmkj0u2k9gvda3steu8t
775193
775192
2026-10-13T15:46:07Z
Mewtow
31375
/* Le renommage dans le tampon de réordonnancement */
775193
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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.]]
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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
mvndww9xbtrsghgd2yzgyiagxl2vdzh
775194
775193
2026-10-13T15:49:15Z
Mewtow
31375
/* Le renommage à banc de registres physiques */
775194
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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. Par contre, il y a une différence avec un banc de registre normal : il est traité comme une mémoire FIFO. Nous avons déjà vu comment implémenter une FIFO avec un banc de registre et quelques registres, aussi je ne fais pas de rappels sur ce point.
S'il s'agissait d'un véritable banc de registre isolé, sans rien de plus, le ROB et le ''scheduler'' contiendraient des numéros de registres virtuels, qui adressent directement le banc de registre renommé. Une telle implémentation est possible, mais aucun processeur commercial n'a utilisé une telle implémentation. À la place, le banc de registre est en réalité une mémoire FIFO, dont les entrées sont adressables, avec de plus un système de cache utilisé pour faire la correspondance entre nom de registre architectural et nom de registre virtuel. Nous détaillerons ce dernier point dans la section qui explique comment fonctionne l'unité de renommage.
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. Et de manière générale, il est impossible de lire cette donnée, que ce soit si le registre est disponible ou réservé.
[[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. 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. De plus, 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>
59ver2pxdybnctvhojjqb76ipmxzupe
775195
775194
2026-10-13T15:55:32Z
Mewtow
31375
/* Le renommage avec un tampon de renommage */
775195
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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>
jrqtzdf0vk9r04fz5sz02zub2iw575w
775196
775195
2026-10-13T17:09:30Z
Mewtow
31375
/* Le renommage physique-virtuel */
775196
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. À noter que pour maintenir des exceptions précises, on est obligé d'attendre que la dernière instruction qui lit le registre ait validé. Le jeu n'en vaut clairement pas la chandelle. Le seul intérêt d'une telle optimisation est économiser des registres virtuels, d'avoir un banc de registres virtuels plus petits, mais cela se fait au prix de l'ajout de circuits qui consomment tout autant, si ce n'est plus de circuits, pour un résultat en termes de performances limité, voire nul.
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>
7b3p6cwxcvkc7sq1xjvk4mqbg39tte0
Fonctionnement d'un ordinateur/L'exécution dans le désordre
0
65869
775248
775076
2026-10-13T22:09:40Z
Mewtow
31375
/* La microarchitecture d'un CPU à exécution dans le désordre */
775248
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est ''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'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique, 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 présence quasiment systématique d'un ROB===
La quasi-totalité des processeurs à exécution dans le désordre utilisent un tampon de réordonnancement. Il permet de gérer les exceptions précises, mais aussi les mauvaises prédictions de branchement et bien d'autres. Il est cependant bien plus gros sur les processeurs OOO que sur les processeurs ''in order''. En effet, il mémorise de quoi annuler les instructions en vol, si une mauvaise prédiction de branchement se produit où quand une exception matérielle survient. Le ROB d'un processeur OOO des années 2020 fait généralement plusieurs centaines d'instructions et approche doucement du millier. En comparaison, un processeur ''in-order'' se débrouille avec un ROB simplifié, qui ne dépasse pas la dizaine d'instructions.
Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre.
===Les défauts de cache 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>
mwsklksttr3mwvqni0lqcuxu8d3h801
775249
775248
2026-10-13T22:10:12Z
Mewtow
31375
/* La présence quasiment systématique d'un ROB */
775249
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est ''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'']]
==Les dépendances de données==
L'exécution dans le désordre est limitée par plusieurs facteurs, l'un d'entre eux étant les '''dépendances de données'''. Le cas typique est celui où une instruction a besoin du résultat d'une instruction précédente pour s’exécuter. C'est le cas le plus évident, mais on a aussi des cas moins intuitifs. Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c'est à dire au même registre ou la même adresse mémoire. En tout, cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent la même donnée, mais pas en même temps.
* '''Read after write''' : Une instruction a pour opérande le résultat d'une autre : elle lit le résultat écrit par la première.
* '''Write after write''' : Deux instructions écrivent dans le même registre ou la même adresse mémoire, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Pour les dépendances ''Read after read'', on peut mettre les deux instructions dans n'importe quel ordre, cela ne pose aucun problème. Ce ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, ce n'est pas le cas avec les trois autres types de dépendances. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Quand on parle de "terminer" ou de "démarrer", on fait référence aux lectures et écritures effectuées par l'instruction. Voici comment sont gérées les trois types de dépendances :
* '''Read after write''' : La première instruction doit écrire son résultat avant que la seconde le lise.
* '''Write after write''' : La première instruction doit écrire son résultat avant la seconde.
* '''Write after read''' : La première doit effectuer la lecture avant que la seconde n'écrive, n'écrase la donnée lue.
: Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Précision importante : les dépendances ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
===Les dépendances de registre et mémoire===
Pour ce qui est des dépendances de données, les accès mémoire se distinguent des autres instructions. Les instructions mémoire ont des dépendances qui leur sont propres, que les autres instructions n'ont pas. Et cela nous amène à parler des dépendances de registre et les dépendances d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité est réservée aux pipelines complexes, qui utilisent une unité mémoire particulière. Nous aborderons le sujet dans le chapitre sur la désambiguïsation mémoire, les chapitres qui vont suivre vont se débrouiller avec une unité mémoire "bloquante", qui ne fait qu'un accès mémoire à la fois.
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites. Et autant les registres lus/écrit sont connus dès que l'instruction est décodée, autant les adresses sont souvent calculées à l'exécution ou mémorisées dans des registres. Les deux types de dépendances sont donc le fait de circuits séparés, qui sont dans des étages de pipeline distincts. La gestion des dépendances d'adresse est le fait de l'unité mémoire et notamment de la file d'écriture, les autres dépendances sont gérées pendant/après l'étape de décodage d'instruction. Aussi, il est important de séparer les deux le plus possible.
===Les dépendances de registre===
Dans ce chapitre, nous allons nous préoccuper des trois dépendances de registres suivantes :
* '''''Read after write''''' : Une instruction lit un registre dans laquelle l'instruction précédente a écrit.
* '''''Write after read''''' : Une instruction lit un registre pour récupérer une opérande, mais ce registre est ensuite écrasé par une instruction ultérieure.
* '''''Write after write''''' : Deux instructions écrivent dans le même registre, mais à des instants différents.
Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances RAW et WAW, imposent que la première instruction ait écrit son résultat avant que la seconde lise ses opérandes. Si on ne fait pas ça, la seconde instruction lit des opérandes invalides ou voit son écriture écrasée par une écriture précédente. Pour éviter cela, la seconde instruction doit être démarrée quand la première s'est totalement terminée. Bloquer le pipeline est donc une nécessité. Mais diverses optimisations permettent de mitiger l'impact des dépendances de données. Par exemple, dans le prochain chapitre, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW. Les dépendances WAW et WAR peuvent aussi être totalement supprimées par des techniques de renommages de registres, qui auront leur chapitre dédié.
Intuitivement, on se dit que les dépendances de registres concernent les instructions arithmétiques. Mais les instructions d'accès mémoire et les branchements aussi ont des dépendances de registres. Les instructions d'accès mémoire ont comme opérande une adresse mémoire, et potentiellement d'une donnée à écrire. Et les deux peuvent être récupérée dans les registres, si le mode d'adressage indirect est utilisé. Idem avec l'adressage indicé, sauf que les opérandes servent à calculer l'adresse.
L'instruction LOAD a aussi un registre de destination, pour accepter la donnée lue. Ce qui fait deux sources de dépendances de registre : le registre opérande, le registre de destination. Si une instruction de calcul ou une autre instruction mémoire lit/écrit dans ces registres, une dépendance de donnée a lieu.
===Les dépendances présentes dépendent du pipeline===
Il faut préciser que les dépendances WAR et WAW n'apparaissent pas sur certains pipelines. Par exemple, sur un pipeline fixe, les écritures se font dans l'ordre du programme, ce qui implique que les dépendances WAW n'existent pas. Les dépendances WAW, quant à elles, n'apparaissent que sur les pipelines dynamiques, ou alors si les écritures prennent plusieurs étages. Et encore, elles sont "résolues" si le processeur utilise un ROB ou toute autre technique de remise en ordre des écritures.
De même, les dépendances d'adresse WAR sont impossibles avec des exceptions précises, car les écritures se font à la fin du pipeline, après les lectures. Et même dans le cas général, les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Pour les processeurs ''in-order'', elles ne se manifestent que dans des pipelines un peu tordues, dans des circonstances où les écritures sont très précoces et les lectures tardives. En pratique, les dépendances WAR n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
{|class="wikitable"
|-
! Dépendance
! Pipeline
! colspan="2" | Solutions
|-
! RAW
| Tous les pipelines
| rowspan="3" | Bloquer le pipeline
| Contournement
|-
! WAW
| Pipelines dynamiques, sans remise en ordre des écritures
| rowspan="2" | Renommage de registres
|-
! WAR
| Pipelines dynamiques avec exécution dans le désordre
|}
Les dépendances WAR et WAW sont souvent appelées des dépendances de nom (''naming dependency'') et sont opposées aux dépendances vraies (''true dependency''), à savoir les dépendances RAW. Les dépendances WAR et WAW viennent du fait qu'un même registre nommé est réutilisé plusieurs fois pour des données différentes. Elles n'existeraient pas si on utilisait un registre pour chaque donnée, qui est écrit une fois, lu une ou plusieurs fois, mais jamais écrasé. Et on peut les faire disparaitre avec des techniques dites de renommage de registre, qui feront l'objet d'un chapitre ultérieur.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique, 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>
0ntswoy41ep9r9o6we60bm4dsfwg4ox
775250
775249
2026-10-13T22:10:48Z
Mewtow
31375
775250
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est ''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.
Les premiers processeurs MIPS avaient une technique très particulière pour éliminer les dépendances structurelles de ce genre, qui éliminait aussi partiellement les dépendances WAW. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications et éventuellement les divisions. Ils ajoutaient un aval séparé, un simple circuit multiplieur/diviseur séparé de l'ALU entière. Les instructions multi-cycles disposaient de leur propre set de registres rien que pour elles, ce qui résolvait pas mal de problèmes de conflits d'accès aux registres.
==La microarchitecture d'un CPU à exécution dans le désordre==
Mine de rien, nous sommes arrivé à un moment important du cours, et faire une sorte de synthèse rapide ne ferait pas de mal. Nous nous sommes assez éloignés d'un processeur avec un pipeline basique, 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>
fsu39d3huiz8nqtco7d0d1dmn8ip529
775251
775250
2026-10-13T22:11:13Z
Mewtow
31375
/* Les dépendances présentes dépendent du pipeline */
775251
wikitext
text/x-wiki
Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter.
Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore.
Mais avant de poursuivre, quelques point niveaux terminologie :
* L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle.
* Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''.
* Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage.
Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''.
L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est ''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>
h6xi6f2veg603l0q1ayd5k6zq983vb7
Fonctionnement d'un ordinateur/Le scoreboard et le ROB
0
65886
775181
775115
2026-10-13T15:13:43Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
775181
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (future file ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (retirement register file ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
cwa51ytymyyxtvoyr3sm4sqq222s4gk
775182
775181
2026-10-13T15:14:59Z
Mewtow
31375
/* Le banc de registres futurs */
775182
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (''Future File'' ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (''Retirement Register File'' ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
eox7z7urwqi2bog75fjotv1dbk5ktla
775183
775182
2026-10-13T15:19:31Z
Mewtow
31375
/* Le tampon d’historique */
775183
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
===Le banc de registres futurs===
Le ROB et le HB sont deux techniques opposées sur le principe. Le ROB part du principe assez pessimiste que le banc de registre doit conserver un état propre, capable de gérer des exceptions précises. L'état temporaire est alors stocké dans le ROB, afin de pouvoir être annulé en cas de souci. La récupération en cas d'exception/branchement est alors assez rapide, mais le cout d'implémentation est assez important. Le HB fait l'inverse, avec une technique optimiste. Le HB enregistre directement l'état temporaire dans le banc de registre, mais mémorise de quoi revenir en arrière. Avec un HB, remettre les registres à l'état normal prend du temps. Deux techniques assez opposées, donc.
Il existe une solution intermédiaire, qui consiste à utiliser à la fois un ROB et un HB. La technique utilise deux bancs de registres. Le premier est mis à jour comme si les exceptions n’existaient pas, et conserve un état spéculatif : c'est le '''banc de registres futurs''' (''Future File'' ou FF). Il fonctionne plus ou moins comme l'''History Buffer'' de la section précédente. L'autre stocke les données valides en cas d'exception : c'est le '''banc de registres de retrait''' (''Retirement Register File'' ou RRF). Il fonctionne sur le même principe que le banc de registre avec un ROB. Il est d'ailleurs couplé à un ROB, histoire de conserver un état valide en cas d'exception. Mais ce ROB est simplifié, comme on va le voir dans ce qui suit.
Le FF est systématiquement utilisé pour l'exécution des instructions. Dès qu'on doit exécuter des instructions, c'est dans ce banc de registre que sont lues les opérandes. La raison est que ce banc de registre contient les dernières données calculées. Notons que dans la technique utilisant seulement un ROB, les opérandes auraient été lues depuis le ROB. Mais là, le ROB n'est plus connecté aux entrées de l'ALU, seul le FF l'est. Le câblage est donc plus simple et l'implémentation facilitée. Le RRF est quant à lui utilisé en cas d'exception ou de branchement, pour récupérer l'état correct.
Après une exception ou un branchement, deux méthodes sont possibles. Avec la première, les opérandes sont lues depuis ce banc de registre, jusqu'à ce que le FF soit de nouveau utilisable. Mais détecter quel banc de registre utiliser est assez compliqué. Elle n'est en pratique pas implémentée, car demandant trop de circuits. L'autre solution est de recopier le contenu du RRF dans le FF. Là encore, le temps de recopie est assez long, sauf si on utilise certaines optimisations des bancs de registre. Il existe en effet des méthodes pour copier un banc de registre entier dans un autre en à peine un ou deux cycles d'horloge. Elles sont assez compliquées et on ne peut pas les expliquer ici simplement.*
[[File:Banc de registres futurs.png|centre|vignette|upright=2|Banc de registres futurs.]]
Une variante de cette technique a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
398l1sd77bj8zp3f95vjsyqxdi5wocb
775184
775183
2026-10-13T15:25:34Z
Mewtow
31375
/* Le banc de registres futurs */
775184
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
===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 a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
055tqe3ma5d0g58vgqczj9c7qnn78j7
775185
775184
2026-10-13T15:25:40Z
Mewtow
31375
/* Le banc de registres futurs */
775185
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
===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 a été utilisée sur le processeur Pentium 4. La différence avec la technique présentée est l'usage du renommage de registre, qui permettait de se passer de deux bancs de registres proprement dit. Les deux bancs de registres étaient inclus dans un banc de registre beaucoup plus grand. Mais nous détaillerons cela dans quelques chapitres.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
bzlfwrbx7783umwjdgjtlx8b0bf0m3y
775186
775185
2026-10-13T15:26:49Z
Mewtow
31375
/* Le banc de registres futurs */
775186
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
===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é.
===Les point de contrôle de registre===
La technique du '''''register checkpointing''''' est une technique qui marche surtout pour les branchements, mais ne gére pas les exceptions précises, du moins pas dans sa version la plus simple. Elle peut être adaptée pour gérer des exceptions précise, mais nous allons simplement voir une version qui gère uniquement les branchements.
Elle consiste à sauvegarder les registres quand un branchement est décodé, pour ensuite restaurer les registres si besoin. Tous les registres architecturaux du processeur sont sauvegardés, et parfois quelques registres microarchitecturaux. En clair, une copie intégrale du banc de registre est réalisée, le registre d'état est lui aussi sauvegardé, etc. La sauvegarde des registres porte le nom de '''point de contrôle de registre''', nous dirons simplement "point de contrôle". Le point de contrôle est stocké dans un autre banc de registre, séparé du banc de registre principal, qui est complétement invisible pour le programmeur.
Un point de contrôle est pris au moment où un branchement est décodé. On ne sait pas si ce branchement sera pris ou non, ce qui fait les instructions qui vont suivre peuvent être correcte si le branchement n'est pas pris, ou incorrectes si le branchement est pris et saute ailleurs dans le programme. Le branchement s'exécute normalement, le banc de registre est modifié par les instructions qui le suivent, tout comme c'est le cas avec un HB. Vers la fin du pipeline, on regarde si le branchement est pris ou non. S'il n'est pas pris, il n'y a rien à faire : les registres contiennent des données valides. Mais si le branchement est pris, alors les registres contiennent des valeurs invalides. Le point de contrôle est restauré, à savoir que le banc de registre est restauré dans le même état qu'un moment du point de sauvegarde.
[[File:Register checkpointing.png|centre|vignette|upright=1.5|Register checkpointing]]
Il est intéressant de comparer cette technique à la technique du tampon d'historique. Le principe est le même : on laisse les instructions modifier les registres, mais on doit annuler ces modifications en revenant en arrière. Sauf que le tampon d'historique restaure les registres un par un. Alors qu'avec un point de contrôle, la restauration du banc de registre se fait en bloc : on restaure tous les registres d'un seul coup, en un seul cycle. Une autre différence est que le point de contrôle ne s’embarrasse pas à savoir quels registres ont été modifiés à tord ou non, tous les registres sont sauvegardés et restaurés en un ou deux cycles d'horloge. Bien sûr, cela demande des bancs de registre spécialement conçus pour. Tout se passe comme si le tampon d'historique étant remplacé par un second banc de registre, avec une procédure de restauration optimisée se faisant en bloc.
La technique peut aussi être comparée avec la technique du banc de registres futurs, elle-même très liée au tampon d'historique. L'idée est que le point de contrôle est le RRF (banc de registre de retirement), alors que le banc de registre normal est un FF (banc de registre futur). La différence est que le point de contrôle fait qu'on n'a pas besoin de savoir quelles sont les modifications à annuler ou non. Le banc de registre tout entier est restauré, pas seulement les registres modifiés à tord. Une autre différence est qu'avec un banc de registre futur, le RFF est mis à jour à chaque cycle d'horloge, dès qu'une instruction peut enregistrer ses résultats. Le point de contrôle n'est pas mis à jour mais est pris en une fois, en un seul cycle d'horloge, et ne change plus après. En conséquence, il n'y a pas besoin de ROB pour gérer l'état du RRF.
==La optimisations liées au ROB==
Dans le reste du cours, nous allons supposer que le processeur utilise un ROB. Les autres techniques sont tellement peu utilisées que ca ne sert à rien d'en parler. Tous les processeurs "haute performance" modernes ont un ROB, les exceptions sont très rares. Le ROB peut être la source de plusieurs optimisations assez intéressantes. Il est possible de lui ajouter plusieurs ports d'écriture, de le connecter au réseau de contournement, etc. Et nous allons voir ces optimisations dans ce qui suit.
===Le contournement avec un ROB===
Dans le chapitre sur les pipelines fixes, nous avons abordé la technique du contournement, qui permet d'utiliser un résultat avant qu'il soit enregistré dans les registres. Pour cela, la sortie de l'unité de calcul était envoyée sur son entrée, grâce à un système impliquant des multiplexeurs. La remise en ordre des écritures ajoute une mémoire pour remettre en ordre les écritures dans les registres. Et l'on peut l'adapter pour gérer le contournement. L'idée est de lire les opérandes depuis la mémoire qui remet en ordre les écritures. Suivant que l'on parle de registres d'échelonnement ou d'un tampon de ré-ordonnancement, le contournement ne se fait pas exactement de la même manière. Voyons pourquoi.
Le ROB peut aussi être adapté pour gérer le contournement. Par exemple, imaginons que le processeur veuille émettre une instruction, dont un des opérandes n'est pas encore dans les registres, mais est déjà dans le ROB. La réaction diffère selon les processeurs. Certains vont simplement bloquer l'instruction dans le pipeline et ne pas l'émettre. D'autres vont autoriser l'instruction à lire l'opérande depuis le ROB. Nous ne détaillerons pas cette technique car il s'agit formellement d'une technique de renommage de registre, qu'on verra dans quelques chapitres en détail.
Avec un ROB, on conserve un réseaux d'interconnexions entre ALU pour gérer le contournement, mais on ajoute aussi des connexions entre ROB et ALU. Les connexions ajoutées permettent de récupérer les opérandes dans le ROB directement. Les comparateurs utilisés pour déterminer s'il faut contourner ou non sont fusionnés avec le ROB, ce qui colle bien avec le fait que c'est une mémoire associative. Les processeurs avec beaucoup d'ALU regroupent leurs ALU en paires ou groupes de 3/4 ALU, effectuer du contournement direct à l'intérieur de ces groupes, mais effectuent du contournement via le ROB entre ces groupes.
[[File:Pipeline avec un ROB.png|centre|vignette|upright=2|Pipeline avec un ROB.]]
Un processeur avec un ''scoreboard'' et un ROB exécute les instructions plus tôt et permet d'utiliser leur résultat en avance. Et c'est un avantage du ROB sur les registres d’échelonnement : les résultats sont disponibles plus tôt, via le réseau de contournement. Les dépendances RAW ont donc moins d'impact.
===Le ''commit'' multiple dans le ROB===
Les instructions écrivent leur résultat dans le ROB, qui les remet dans l'ordre si besoin. Vu que les instructions enregistrent leurs résultats dans le désordre, il peut arriver que deux instructions veuillent enregistrer leur résultat dans le ROB, en même temps. Par exemple, imaginons que l'on exécute une multiplication de 3 cycles, suivie au cycle suivant par une opération qui n'en prend que 2. Elles tenteront d'enregistrer leurs résultats en même temps 3 cycles après pour la première, 1 + 2 cycles pour l'autre. Elles voudront donc enregistrer un résultat en même temps. Et il faut gérer la situation.
La solution la plus simple est que l'unité d'émission détecte ce genre de conflit d'accès au ROB, et retarde la seconde instruction d'un cycle pour résoudre le problème. Pour cela, elle utilise un ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
nhbkqs4z011zeaue6h40zm0p50flclp
775187
775186
2026-10-13T15:30:06Z
Mewtow
31375
/* La remise en ordre des écritures */
775187
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
4tobg60ut1exqsy7e2t9eu16yfn2z0w
775197
775187
2026-10-13T17:38:02Z
Mewtow
31375
/* Les exemples réels de processeurs avec un scoreboard */
775197
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les pipelines in-order
| prevText=Les pipelines in-order
| next=L'exécution dans le désordre
| nextText=L'exécution dans le désordre
}}
</noinclude>
s4b1ti8vt0nsxaxear2urbb9503wr2a
775199
775197
2026-10-13T18:24:27Z
Mewtow
31375
/* Les dépendances Write After Write */
775199
wikitext
text/x-wiki
Le chapitre précédent portait sur les processeurs ''in-order''. Ils sont opposés aux processeurs avec exécution dans le désordre, qui sont justement le sujet de ce chapitre. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. Pourtant, à la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Il peut donc y avoir une '''inversion de l'ordre des écritures''', sous entendu, écritures dans les registres.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Les processeurs ''in-order'' garantissent que des instructions termineront dans l'ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner. Les processeurs à exécution dans le désordre, eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance, mais utilisent un système d'exception précise sous stéroïde pour remettre en ordre les écritures. Et il est temps d'aborder une forme bien précise d'exécution dans le désordre. Les processeurs en question sont appelés des processeurs ''in-order issue - out-of-order commit - in-order writeback''. Okay, j'ai rien dit, on va parler de '''processeurs à ''scoreboard'''''.
Les processeurs de ce chapitre ressemblent fortement aux processeurs ''in-order''. 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==
L'inversion de l'ordre des écritures pose problème pour supporter les exceptions précises. Il est possible qu'une instruction lève une exception alors qu'une instruction suivante a déjà enregistré son résultat. Imaginez par exemple qu'une multiplication soit suivie par une addition. Imaginez que la multiplication lève une exception matérielle du type "débordement d'entier", 3 cycles après avoir démarré. L'addition aura déjà enregistré son résultat, vu qu'elle se fait en un seul cycle...
Pour conserver l'ordre des écritures, un processeur ''in-order'' ''retarde l'exécution de certaines instructions avec des dépendances''. Nous avions vu cela dans le chapitre sur les processeur ''in-order'', avec le ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Une solution plus simple 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. Pour cela, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire.
<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>
hvp7i2t2dhcw6zy2kuz4k3xxwz9s9iv
775200
775199
2026-10-13T18:42:33Z
Mewtow
31375
775200
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 ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Une solution plus simple 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. Pour cela, l'unité de ''writeback'' est connectée aux registres de pipeline, qui transmettent les registres à lire.
<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>
0d5ujy5bxx9hzw7jtz3nso5ocxq17hw
775201
775200
2026-10-13T18:56:00Z
Mewtow
31375
/* Les dépendances Write After Read */
775201
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 ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Une solution plus simple 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.
<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>
cweczjnar7n00t8faxusu9tnwjn93hv
775202
775201
2026-10-13T19:04:35Z
Mewtow
31375
/* Les dépendances Write After Read */
775202
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 ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Une solution plus simple 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|Détection des dépendances WAR]]
<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>
3id3vw3m69bgvj0ccnw34pdxsaw4mxt
775203
775202
2026-10-13T19:04:47Z
Mewtow
31375
/* Les dépendances Write After Read */
775203
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 ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Une solution plus simple 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]]
<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>
33n7vzp6zktn2hpmdruc50nu8mo2sje
775204
775203
2026-10-13T19:13:26Z
Mewtow
31375
/* Les dépendances Write After Read */
775204
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 ''result shift register'', je ne reviens pas dessus. Les processeur avec un ''scoreboard'' utilisent une autre solution : exécuter les instructions en avance, mais '''retarder les écritures dans les registres''' à la toute fin du pipeline. Ainsi, il n'y a pas d'inversion d'ordre des écritures dans les registres, ni d'écritures simultanées.
Une solution similaire, déjà abordée dans les chapitres sur les pipelines fixes, utilise des registres d'échelonnement. Mais un défaut est que cette solution utilise un pipeline fixe. Impossible d'avoir un pipeline dynamique, ni d'avoir des défauts de cache non-bloquants. Les techniques qu'on va voir dans ce qui suit utilise une version sous stéroïde de ces registres d’échelonnement, qui sont compatibles avec un pipeline dynamique, non-fixe. Ils permettent d'avoir des exceptions précises, alors que les instructions terminent dans le désordre.
===Le tampon de réordonnancement===
Le '''tampon de réordonnancement''' est la technique phare utilisée pour remettre les écritures dans l'ordre. L'idée est là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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 ''result shift register'' modifié, qui mémorise si à tel cycle du pipeline, le ROB recevra une écriture. Il permet de réserver le port du ROB pour un cycle bien précis.
Concrètement, prenons une instruction met N cycles à s’exécuter, qui fournit un résultat à enregistrer au cycle N. Lorsqu'elle entre dans l'unité d'émission, le énième bit du ''result shift register'' est vérifié. S'il vaut zéro, alors le ROB est inutilisé pendant ce cycle. L'instruction réserve alors ce cycle pour écrire dans le ROB, en mettant le bit à 1. Et uniquement ce bit, pas les précédents, petite nuance comparé au ''result shift register'' vu plus haut. S'il est déjà à 1, le port d’écriture du ROB a été réservé pour ce cycle, la micro-opération est mise en attente.
{|class="wikitable"
|+ Result Shift Register, réservation du ROB
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6
|-
| 0 || 1 || 0 || 1 || 1 || 0 || 1
|}
Une autre solution ajoute des ports d'écriture sur le ROB, afin qu'il puisse recevoir plusieurs résultats par cycle. Il y a typiquement un port d'écriture par unité de calcul, sur le ROB. La première solution a un cout en performance, mais est simple à implémenter et son cout en transistor est limité. Pour la seconde solution, son cout en transistor est important, sans compter que le ROB devient alors plus gourmand en énergie. La solution est surtout utilisée sur les processeurs avec beaucoup d'unités de calcul.
===La complétion dans le désordre===
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 ''result shift register'', qui permet d'interdire une écriture pendant N cycles d'horloge consécutifs. Sauf que cette fois-ci, il y a un ''result shift register'' par registre. Une instruction qui compte écrire dans un registre après N cycles réserve un registre pendant N cycles. L'unité d'émission configure le ''result shift register'' adéquat pour cela. Le registre est alors inaccessible en écriture pendant N cycles. Si une instruction ultérieure tente de le faire, c'est signe qu'il y a une dépendance WAW. L'instruction candidate est alors bloquée.
===Les dé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 alors très compliqué. Il faut consulter les ''result shift register'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''result shift registers'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
n04kwnwd43tynewv0d74jf6uh88fn2b
775206
775204
2026-10-13T19:26:16Z
Mewtow
31375
775206
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 là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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.
: L'''history buffer'' a une ressemblance avec une technique de renommage de registre, appelée le renommage à ban de registres physiques. La différence est que l'''history buffer'' est partie intégrante de l'unité de renommage de registre. 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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
f7ftkm6vhsccmn6q0vhuvjduf33aieh
775207
775206
2026-10-13T19:28:51Z
Mewtow
31375
/* Le tampon d’historique */
775207
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 là encore de remettre les écritures dans l'ordre. Les micro-opérations fournissent leurs résultats dans le désordre, mais les résultats sont mis en attente, afin que les écritures se fassent dans l'ordre. La mise en attente se fait dans une mémoire appelée le tampon de ré-ordonnancement, ''Re-order buffer'' en anglais, ce qui fait que j'utiliserais parfois l'abréviation ROB dans ce qui suit.
La différence avec les registres d’échelonnement est que la mise en attente est plus flexible, elle ne demande pas que toutes les instructions prennent le même nombre de cycles pour s'exécuter. Une instruction peut enregistrer son résultat dès que les précédentes sont terminées, au cycle précis où elles ont toutes enregistrées leurs résultats. Dit autrement, seule l'instruction la plus ancienne peut quitter le ROB et enregistrer son résultat, les autres instructions doivent attendre. De ce point de vue, le tampon de réordonnancent est donc une mémoire FIFO. On peut la voir comme une amélioration de la technique précédente, où les registres d’échelonnement sont remplacés par une structure matérielle plus complexe.
[[File:Tampon de réordonnancement.png|centre|vignette|upright=2|Tampon de réordonnancement.]]
Les résultats sont enregistrés dans le désordre dans le ROB, mais finissent triés par l'ordre du programme. L'ordre d'écriture demande de mémoriser l'ordre d'exécution des instructions. Pour cela, on profite du fait que les instructions sont décodées dans l'ordre. Après les décodeurs se trouve un circuit appelé l'unité d'émission, qu'on détaillera dans le chapitre suivant. Pour simplifier, elle vérifie que l'instruction qu'elle reçoit peut s'exécuter, et l'envoie aux unités de calcul si c'est le cas. L'unité d'émission ajoute l'instruction au ROB quand elle l'envoie au chemin de données. Si le ROB est plein, on ne peut pas y ajouter de nouvelle instruction. En conséquence, le processeur bloque les étages de chargement, décodage, etc. Le processeur est débloqué quand une instruction enregistre ses résultats.
Un processeur avec un tampon de ré-ordonnancement est donc composé de trois sections : un ''front-end'' qui regroupe chargement et décodage, un chemin de données (ALU et registres), et un système complémentaire de ré-ordonnancement. Ce dernier est composé du tampon de ré-ordonnancement, de l'unité d'émission et de l'étage de ''writeback'' pour l'enregistrement dans les registres. Le ROB envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
[[File:Processeur avec un tampon de ré-ordonnancement.png|centre|vignette|upright=2|Processeur avec un tampon de ré-ordonnancement]]
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
kgtfjr5lv343a3iqc4ife6u2nkcg3x9
775214
775207
2026-10-13T19:33:23Z
Mewtow
31375
/* Le tampon de réordonnancement */
775214
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 envoie toutes les informations nécessaires à l'étage de ''Writeback'', à savoir le registre de destination, l'indicateur d'exception, le résultat à enregistrer, etc.
Les résultats sont enregistrés dans le ROB dans le désordre. Cependant, le ROB fait le lien entre un résultat et l'instruction qui l'a produite, il associe une instruction avec son résultat. Expliquer comment est fait ce lien demande d'expliquer comment est implémenté le ROB. Pour simplifier, c'est un subtil mélange entre une mémoire cache et une mémoire FIFO. Le ROB est composé d''''entrées''', chacune contient de quoi faire le lien entre un résultat et l'instruction associée.
Une entrée contient le ''program counter'' associé à l'instruction, le registre de destination du résultat, un champ pour mémoriser le résultat, et un ''bit de présence''. Ce dernier indique si le résultat a bien été calculé. Le champ résultat est initialement laissé vide, mais le résultat de l'instruction est copié dedans une fois qu'il est disponible. Notons que certaines instructions ne renvoient pas de résultat, comme les branchements ou les écritures en mémoire, mais on leur alloue quand même une entrée dans le ROB. Pour cela, le bit de présence est couplé à un bit qui indique si l'instruction doit fournir un résultat ou non. Les deux sont regroupés sous le noms de bits de présence. Si le processeur gère des exceptions précises, il faut ajouter un champ pour l'indicateur d'exception, qui est utilisé pour gérer des exceptions précises.
{|class="wikitable"
|+ Une entrée du ROB
|-
| ''Program counter'' || Registre de destination || Champ résultat || Bits de présence || Champ exception
|}
Lorsqu'une instruction est émise, elle réserve une entrée du ROB, pour que son résultat soit ajouté dedans ultérieurement. L'émission se faisant dans l'ordre, les entrées du ROB sont allouées dans l'ordre. Le ROB est donc une sorte de mémoire FIFO du point de vue des instructions. Une instruction réserve une entrée du ROB en écrivant son ''Program counter'' dedans, et en remplissant le champ pour le registre de destination. Le champ
Par la suite, les différents résultats vont être enregistrés dans les entrées adéquates, dans l'entrée réservée par l'instruction. Pour faire le lien entre un résultat et une instruction, le ROB fonctionne comme un cache d'instruction dont le ''tag'' serait le ''program counter''. Le ''program counter'' est propagé dans le pipeline, d'étage en étage, ce qui fait que le résultat sortira de l'unité de calcul en étant associé à ce ''program counter''. Le ''program counter'' est alors utilisé pour accéder au ROB comme on le ferait avec un cache d'instruction. L'entrée qui déclenche un succès de cache est alors sélectionnée : le résultat est copié dedans.
Une fois que toutes les instructions précédentes sont terminées, l'instruction peut enregistrer son résultat dans les registres. instruction quitte alors le ROB, dans le sens où l'entrée est vidée. Le résultat à enregistrer est lu depuis le champ résultat, le registre de destination est lu depuis le champ du même nom. L'étage de ''Writeback'' vérifie que le résultat est disponible en vérifiant les bits de présence. L'étage de ''writeback'' peut aussi vérifier si l'instruction a levé une exception matérielle, en regardant le champ Exception. Si une exception a lieu, cela signifie que les instructions suivantes sont invalides. Le ROB est alors vidé, ce qui le débarrasse des instruction invalides : leurs résultats ne seront pas enregistrés dans les registres architecturaux. Le même mécanisme est utilisé pour gérer les mauvaises prédictions de branchement.
===Le tampon d’historique===
Une autre solution laisse les instructions écrire dans les registres dans l'ordre qu'elles veulent, mais conserve des informations pour remettre les écritures dans l'ordre, pour retrouver les valeurs antérieures. Ces informations sont stockées dans ce qu'on appelle le '''tampon d’historique''' (''history buffer'' ou HB).
Le tampon d'historique est une mémoire LIFO dont chaque mot mémoire est une entrée qui mémorise les informations dédiées à une instruction. Lorsqu'une instruction modifie un registre, le HB sauvegarde une copie de l'ancienne valeur, pour la restaurer en cas d'exception. Pour annuler les modifications faites par des instructions exécutées à tort, on utilise le contenu de l'HB pour remettre les registres à leur ancienne valeur. Plus précisément, on vide le HB dans l'ordre inverse d'ajout des instructions, en allant de la plus récente à la plus ancienne, jusqu'à vider totalement le HB. Une fois le tout terminé, on retrouve bien les registres tels qu'ils étaient avant l’exécution de l'exception.
[[File:Tampon d’historique.png|centre|vignette|upright=2|Tampon d’historique.]]
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
gn0ujgqsdodavsgrdqbhb1tqy2y2js6
775220
775214
2026-10-13T19:37:30Z
Mewtow
31375
/* Le tampon de réordonnancement */
775220
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.
===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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
esirm9jljwpf743i6s6w7ahqx8jsugd
775225
775220
2026-10-13T19:40:05Z
Mewtow
31375
/* Le tampon de réordonnancement */
775225
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
===Les exemples réels de processeurs avec un ''scoreboard''===
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
4jsc8r20ri12q4kqxwz1yuwa5c8gopp
775229
775225
2026-10-13T19:40:58Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775229
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, on n'aurait pas besoin de ROB ou de tampon d'historique, de banc de registre futur, etc. Par contre, au-delà des problèmes liés aux exceptions précises, l'inversion de l'ordre des écritures pose d'autres problèmes, comme on le verra plus bas.
Bizarrement, les exemples de processeurs de ce type sont plus nombreux, que les processeurs avec un ''scoreboard'' et un ROB. 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. Et 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 concepteurs de processeur avec un budget en transistor conséquent préférera utiliser de la vraie exécution dans le désordre telle qu'on l'abordera dans le chapitre suivant, 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. En théorie, du fait de l’absence d'exceptions précises, ils ne peuvent pas utiliser de prédiction de branchement. Et c'est un compromis que peu de processeurs acceptent de faire.
Les seules utilisations se trouvent dans les systèmes embarqués, et même là, ils sont rares. Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
Un exemple possible est celui des processeurs Xscale d'Intel, qui implémentaient le jeu d’instruction ARM v5, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
4yjchi47ypsgurq3zjay85f1gwruopw
775236
775229
2026-10-13T20:20:18Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775236
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, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
===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 alors très compliqué. Il faut consulter les ''registre de réservation'' , mais en utilisant des masques bien conçus. Le ''scoreboard'' sait à quel cycle chaque instruction lit ses registres. Il génére alors un masque, qui indique à quel cycle sont lus tel ou tel registre. Par exemple, si une instruction lit un registre au cycle 3, le masque est alors 0000 0100. Le masque est alors comparé aux ''registre de réservations'' adéquats, ceux des registres lus. La comparaison est un simple ET logique : il y a une dépendance WAR si le résultat 1, pas de dépendances s'il vaut 0
Une solution plus simple 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]]
<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>
oxt8r774gx28w0c0j6vvbrq1cb4wscs
775237
775236
2026-10-13T20:32:40Z
Mewtow
31375
/* Les dépendances Write After Read */
775237
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, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient bien un ''scoreboard'', mais sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et ont été vendus sous dans du matériel réseau (routeurs, ''network processors''), et dans quelques''System On Chip''.
Les processeurs Xscale avaient deux voies : une voie regroupant une ALU entière et une unité mémoire, et une voie se résumant à une unité de calcul MAC (''Multiply And Accumulate'') qui travaillait sur des nombres entiers. L'usage d'un ''scoreboard'' avec deux/trois voies était relativement simple. Le ''scoreboard'' permettait d'exécuter des instructions dans l'ALU entière en parallèle d'un accès mémoire, mais aussi d'exécuter une opération MAX en parallèle. Les instructions MAC étaient des instructions multi-cycles et l'unité MAC n'était pas pipeliné, alors que l'unité mémoire et l'ALU entière l'étaient.
{|class="wikitable"
|-
! rowspan="4" | Fetch 1 !! rowspan="4" | Fetch 2 !! rowspan="4" | Decode !! rowspan="4" | Read Operand
| rowspan="2" | ALU entière/Calcul adresse || Accès cache L1 || Accès cache L1 || ''Writeback''
|-
| Gestion registre état + autres || ''Writeback''
|-
| colspan="8" |
|-
| MAC || MAC || MAC (facultatif) || MAC (facultatif) || ''Writeback''
|}
Une autre utilisation est celle des processeurs de ''shaders'' des GPU modernes. Ils implémentent tous 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.
===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]]
<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>
75sd14nfxg80mg07cthcqbbjjts3rk1
775238
775237
2026-10-13T20:43:31Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775238
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, avec cependant l'absence d'instructions flottantes. Si j'en crois le manuel d'optimisation officiel (''Intel® XScale™ Core Optimization Guide'', section A.2), ils utilisaient un ''scoreboard'' sans système pour remettre en ordre les écritures. Ces processeurs étaient prévus pour une utilisation embarquée, et 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. Ils implémentent tous 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.
===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]]
<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>
1sygb7zbouuyppqxe3h4ee7x0lc48h8
775240
775238
2026-10-13T20:46:59Z
Mewtow
31375
/* Annexe : le scoreboard sans exceptions précises */
775240
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.
===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]]
<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>
d18tq1esyfql0grzz65k0lgwdeofzve
775241
775240
2026-10-13T21:06:17Z
Mewtow
31375
/* Le replay pipeline du Pentium 4 */
775241
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.
==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. Le ROB est cependant partagé, 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.
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.
==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.
===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]]
<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>
td2ssou5d47v5jzcf4d1vezgnfkiizx
775242
775241
2026-10-13T21:06:32Z
Mewtow
31375
/* Les scoreboards décentralisés */
775242
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.
===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]]
<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>
d18tq1esyfql0grzz65k0lgwdeofzve
775243
775242
2026-10-13T21:09:17Z
Mewtow
31375
/* Les dépendances Write After Read */
775243
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.
===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>
lep8l9v9805hk9l3g1336uex3jqz7aq
Fonctionnement d'un ordinateur/Le pipeline
0
65892
775142
775103
2026-10-13T13:43:05Z
Mewtow
31375
/* Le pipeline : rien à voir avec un quelconque tuyau à pétrole ! */
775142
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=2|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
80c3gmbdlwi2mifs58ovxd3fs67fmut
775143
775142
2026-10-13T13:46:03Z
Mewtow
31375
/* Le pipelining du chemin de données */
775143
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
smoojsw6zemnudrakpdhjkk59foivn5
775144
775143
2026-10-13T14:00:21Z
Mewtow
31375
/* Les étages du pipeline */
775144
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
2k3vg5wb2kzsj89accexbx9twu8oszf
775145
775144
2026-10-13T14:00:42Z
Mewtow
31375
/* Les étages du pipeline */
775145
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
i32ldj9iucp7y89eozioqjr3p0ocwp4
775146
775145
2026-10-13T14:00:54Z
Mewtow
31375
/* Les pipelines fixes et variables */
775146
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en oeuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour mettre à jour le ''program counter'' lors du chargement, calculer des adresses lors d'un accès mémoire, les additions, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
69t10jbenhskg1fvvaapx7wc1nxv1g1
775147
775146
2026-10-13T14:02:49Z
Mewtow
31375
/* Les étages du pipeline */
775147
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Et cela parait simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce peut achopper sur quelques points de détail.
Par exemple, sur un processeur sans pipeline, l'additionneur de l'ALU peut être utilisé pour les branchements relatifs, calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
nq1tre5ky5l335117a64p5tr4lxpx17
775148
775147
2026-10-13T14:05:51Z
Mewtow
31375
/* Les étages du pipeline */
775148
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. Deux circuits sont notablement concernés par ce problème : le ou les bancs de registres, l'accès à la mémoire. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra plus bas. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture.
Mais on verra que d'autres exemples de ce type sont assez fréquents et qu'il faut gérer la situation. Par exemple, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
34r1jm41496l4cz04efvym2jft4vvjd
775149
775148
2026-10-13T14:10:22Z
Mewtow
31375
/* Les étages du pipeline */
775149
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils sont nettement plus complexes que les pipelines fixes. Tellement complexes que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
jwmv5gvuonvon582ga8xhd3qlzkveuz
775150
775149
2026-10-13T14:15:05Z
Mewtow
31375
/* Les pipelines fixes et variables */
775150
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
btwcxzzef5pr81747hcr0gtd8c994ry
775151
775150
2026-10-13T14:16:38Z
Mewtow
31375
/* Les pipelines fixes et variables */
775151
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Cependant, découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
76tw35mu0j953onw4mufckt8y446nor
775172
775151
2026-10-13T14:44:40Z
Mewtow
31375
/* Le pipeline Fetch-Exec */
775172
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
dwozezuvq4aobm7ckafd89he1qun5x2
775173
775172
2026-10-13T14:46:16Z
Mewtow
31375
775173
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des autres cours, notamment les ''textbooks'' américains, préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres, des processeurs très influents et très populaire à leur époque. Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique.
Le pipeline RISC classique est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Je vais cependant parler du pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
sztrk34htsdvn9rwpqygzujre683u5n
775174
775173
2026-10-13T14:47:50Z
Mewtow
31375
/* Le pipeline RISC classique et ses variantes */
775174
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des ''textbooks'' américains préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres. Des processeurs très influents et très populaires à leur époque.
Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique. Il est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il a beau avoir un nombre limité d'étages, il est en réalité optimisé d'une manière assez subtiles, et ces optimisations ne sont pas intuitives. Je vais cependant aborder ce pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
0jld75coguff4p2tweuak7ephjl01ct
775176
775174
2026-10-13T14:50:06Z
Mewtow
31375
/* Le pipeline RISC classique et ses variantes */
775176
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des ''textbooks'' américains préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres. Des processeurs très influents et très populaires à leur époque.
Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique. Il est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il a beau avoir un nombre limité d'étages, il est en réalité optimisé d'une manière assez subtiles, et ces optimisations ne sont pas intuitives. Je vais cependant aborder ce pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU. Le principe est que cela permet d'exécuter les branchements en avance, ce qui a des avantages qui deviendront évident au chapitre suivant, quand nous parlerons des problèmes que causent les branchements pour un pipeline. Disons que pour une utilisation optimale du pipeline, les branchements doivent être exécutés le plus tôt possible.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Le pipeline permet d’exécuter plusieurs instructions en même temps, mais chaque instruction met presque autant de temps à s’exécuter avec un pipeline idéal. Elle est juste découpées en plusieurs étapes courtes au lieu d'être faite en une fois. Une autre manière de le voir est de remarquer que si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Étudier l'impact sur la performance du processeur n'est pas trivial. Peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d'execution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre. Un bon moyen de s'en rendre compte est de remarquer qu'avec un pipeline, f et CPI sont tous les deux multipliés par N, ce qui fait que le terme de droite ne change pas, de même que le terme N reste le même. Mais alors d'où vient l'amélioration de performance tant promise ?
Elle vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
l00gupptam6rn2h5r62fi91gvg8ra8m
775178
775176
2026-10-13T14:54:29Z
Mewtow
31375
/* La performance théorique d'un pipeline idéal (approche simplifiée) */
775178
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des ''textbooks'' américains préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres. Des processeurs très influents et très populaires à leur époque.
Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique. Il est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il a beau avoir un nombre limité d'étages, il est en réalité optimisé d'une manière assez subtiles, et ces optimisations ne sont pas intuitives. Je vais cependant aborder ce pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU. Le principe est que cela permet d'exécuter les branchements en avance, ce qui a des avantages qui deviendront évident au chapitre suivant, quand nous parlerons des problèmes que causent les branchements pour un pipeline. Disons que pour une utilisation optimale du pipeline, les branchements doivent être exécutés le plus tôt possible.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Certes, la fréquence est multipliée par N. Mais une instruction qui mettait un cycle d'horloge pour s'exécuter en prend maintenant N. Les deux phénomènes se compensent exactement, le temps d'exécution d'une instruction reste le même. Si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Le gain en performance vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. Il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
Au passage, peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d’exécution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
En multipliant par N, on obtient le le temps d’exécution d'une instruction. On voit que ce dernier est égal au temps sans pipeline, auquel on ajoute la latence des registres inter-étages. Le temps d’exécution d'une instruction est donc allongé avec un pipeline.
: <math>T = N \times \left( \frac{t}{N} + \tau \right) = t + \tau \times N</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à la fréquence du même processeur mais sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étage, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres.
On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N t}{N \tau + t} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
n5ea83d20b2662mlnsvwdcrdftmvg2u
775179
775178
2026-10-13T14:59:11Z
Mewtow
31375
/* La performance théorique d'un pipeline avec des registres inter-étages */
775179
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des ''textbooks'' américains préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres. Des processeurs très influents et très populaires à leur époque.
Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique. Il est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il a beau avoir un nombre limité d'étages, il est en réalité optimisé d'une manière assez subtiles, et ces optimisations ne sont pas intuitives. Je vais cependant aborder ce pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU. Le principe est que cela permet d'exécuter les branchements en avance, ce qui a des avantages qui deviendront évident au chapitre suivant, quand nous parlerons des problèmes que causent les branchements pour un pipeline. Disons que pour une utilisation optimale du pipeline, les branchements doivent être exécutés le plus tôt possible.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Certes, la fréquence est multipliée par N. Mais une instruction qui mettait un cycle d'horloge pour s'exécuter en prend maintenant N. Les deux phénomènes se compensent exactement, le temps d'exécution d'une instruction reste le même. Si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Le gain en performance vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. Il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
Au passage, peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d’exécution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à un processeur sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étages, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres. On peut calculer le gain en comparant la fréquence avec et sans pipeline :
: <math>\frac{f_\text{Avec pipeline}}{f_\text{Sans pipeline}} = \frac{N}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à N et on voit l'influence des N registres. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
01ozy7o23izgu4sle2u5yqxj0c1onu0
775180
775179
2026-10-13T15:03:06Z
Mewtow
31375
/* La performance théorique d'un pipeline avec des registres inter-étages */
775180
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir la technique du '''pipeline d'instruction''', qui est utilisée sur tous les processeurs modernes. L'introduction du pipeline a eu lieu sur les premiers ''mainframes'' et les supercalculateurs, dans les années 60-70. Elle a ensuite été reprise pour les premiers processeurs RISC commerciaux. Cependant, elle s'est démocratisée aux processeurs CISC modernes. Introduire un pipeline entraine une modification profonde de la microarchitecture du processeur, ainsi que l'ajout de nombreuses optimisations qui ne seraient pas possibles dans pipeline : exécution superscalaire, exécution dans le désordre, prédiction de branchements, etc. Nous passerons une dizaine de chapitre à parler du pipeline des processeurs modernes, ainsi que toutes les techniques qui vont avec.
==Le pipeline : rien à voir avec un quelconque tuyau à pétrole !==
Pour expliquer ce qu'est un pipeline d'instructions, il faut faire un rappel sur les différentes étapes d'une instruction. Dans le chapitre sur la micro-architecture d'un processeur, on a vu qu'une instruction est exécutée en plusieurs étapes bien distinctes : le chargement, le décodage, et diverses étapes pour exécuter l'instruction. Ces dernières dépendant du processeur, de l'instruction, du mode d'adressage, etc. Pour comprendre ce que fait le pipeline, comparons l’exécution de cette instruction sans et avec pipeline. Dans les exemples qui vont suivre, nous allons supposer que les instructions se font toutes en 5 étapes, qu'on ne détaillera pas, car ce n'est pas important à ce stade du chapitre. L'essentiel est qu'une instruction se fasse en plusieurs étapes, point.
Sans pipeline, ces étapes sont réalisées les unes après les autres, la précédente doit être terminée avant de démarrer la suivante. De plus, on doit attendre qu'une instruction soit finie pour exécuter la suivante. L'instruction exécute toutes ses étapes, avant que l'instruction suivante démarre à l'étape 1.
[[File:Nopipeline.png|centre|vignette|upright=2.0|Exécution de trois instructions sans pipeline.]]
Avec un pipeline, on exécute plusieurs instructions différentes, chacune étant à une étape différente des autres. Pour cela, on démarre une nouvelle instruction par cycle (dans des conditions optimales). Une instruction passe d'une étape à la suivante à chaque cycle d'horloge. A chaque instant, toutes les étapes travaillent sur une instruction différente.
[[File:Fivestagespipeline.svg|centre|vignette|upright=2.0|Exécution de trois instructions avec pipeline.]]
Pour comprendre le diagramme précédent, il faut comprendre à un instant t, il y a plusieurs instructions, chacune à une étape différente. Un instant t correspond à une colonne dans le diagramme précédent. Le diagramme suivant le montre bien. La colonne rouge correspond à un cycle d'horloge, la jaune immédiatement suivant est le cycle d'horloge suivant, idem pour le bleu.
[[File:Pipeline chaîne de traitement.png|centre|vignette|upright=2.0|Pipeline : principe.]]
Une illustration plus intuitive est la suivante. Chaque instruction correspond à une couleur :
[[File:Pipleine-3.png|centre|vignette|upright=2.0|Pipeline à 5 étages.]]
Le pipeline contient donc plusieurs instructions, qui sont à des étapes différentes de leur exécution. Les instructions en question sont appelées des '''instructions en vol''', terme de mon cru que vous ne trouverez ''a priori'' que dans ce cours.
===Les étages du pipeline===
Mettre en œuvre un pipeline nécessite des modifications profondes de la micro-architecture du processeur. En général, le découpage en étage suit une certaine logique. Il y a un étage pour l'unité de chargement, un étage pour le décodage de l'instruction, un étage pour lire les opérandes d'une instruction, un étage pour l'unité de calcul/unité mémoire, et un étage pour enregistre le résultat dans les registres. La séparation des étages se fait donc en utilisant les séparations existantes. Il n'y a pas besoin de refaire tout le processeur de zéro. Le tout parait donc être très simple : séparer l'unité de chargement, le décodeur et le chemin de données a l'air assez facile. Cependant, ce découpage simple achopper sur quelques points de détail.
Tout d'abord, chaque étape d'une instruction doit s'exécuter indépendamment des autres, ce qui signifie utiliser des circuits indépendants pour chaque étape. Or, un processeur sans pipeline tend à mutualiser certains circuits entre différentes étapes. Par exemple, sur un processeur 16 bits sans pipeline, l'additionneur de l'ALU peut être utilisé pour incrémenter le ''program counter'', calculer des adresses lors d'un accès mémoire, exécuter les instructions d'addition, etc. Mais avec un pipeline, on ne peut pas se le permettre, chaque étape doit utiliser son propre additionneur. En général, il est impossible de réutiliser un circuit dans plusieurs étapes, comme on le fait dans certains processeurs sans pipeline. Chaque circuit dédié à une étape est appelé un '''étage du pipeline'''.
Le nombre d'étapes par instruction, et donc le nombre d'étages du pipeline, est appelé la '''profondeur du pipeline'''. Il correspond au nombre maximal théorique d'instructions en vol, au nombre d'instructions exécutées en même temps dans le pipeline. Et j'ai bien dit maximal théorique : ce maximum n'est pas toujours atteint, comme on le verra plus tard. Pour les curieux, voici les longueurs de pipeline de certains processeurs plus ou moins connus.
{|class="wikitable"
|-
! Processeur !! Longueur du pipeline
|-
| Motorola PowerPC G4 || 7
|-
| MIPS R4400 || 8
|-
| Intel Itanium || 10
|-
| Intel Pentium II || 14
|-
| Intel Pentium 3 || 10
|-
| Intel Pentium 4 ||20
|-
| Intel Pentium 4 Prescott ||31
|-
| AMD Athlon ||12
|-
| AMD Athlon 64 ||16
|-
| IBM PowerPC 970 ||16
|-
| Sun UltraSPARC IV ||14
|}
Plus haut, il a été dit que les étages du pipeline sont censés être totalement séparés, mais la réalité est beaucoup plus complexe. Dans la réalité, il existe quelques circuits qui sont partagés entre plusieurs étages, car on n'a pas le choix. En conséquence, deux instructions peuvent accéder à la mémoire simultanément, exécuter une micro-opération dans la même unité de calcul, lire le même registre en même temps, etc. De telles '''dépendances structurelles''' surviennent quand plusieurs instructions veulent accéder à un circuit du processeur en même temps. Les processeurs modernes incorporent de nombreux circuits capables de gérer les dépendances structurelles, qu'on abordera au fur et à mesure que les occasions se présentent.
Cependant, même si un circuit est partagé par un même étage, cela ne signifie pas que tout est perdu. Prenons par exemple le cas du banc de registres. Il est fréquent qu'un registre soit lu dans un étage et écrit dans un autre, comme on le verra dans ce qui suit. Heureusement, le banc de registre est conçu pour gérer la situation, car il a des ports de lecture séparés des ports d'écriture. Il y aura donc un étage pour lire dans les registres, un étage pour écrire dedans. Et ces deux étages utilisent des ports séparés, ce qui fait qu'il n'y a pas de dépendances structurelles.
===Les pipelines fixes et variables===
Les pipelines peuvent se classer en deux types, suivant leur "nombre d'étage global". La distinction sépare les pipelines de longueur fixe des pipelines de longueur variable.
Les pipeline de longueur fixe ont un nombre d'étages fixe, qui est le même pour toutes les instructions. De tels pipelines sont aussi appelés des '''pipelines fixes'''. Ce sont les pipelines les plus intuitifs, mais ils ont un gros défaut : la gestion des instructions multi-cycles. Le problème est qu'il y a des instructions plus rapides que d'autres, les plus rapides mettant un cycle pour s’exécuter, d'autres mettant plusieurs cycles. Or, on voit mal comment un pipeline fixe pourrait gérer des instructions qui s'exécutent en un cycle d'un côté, et des instructions qui s’exécutent en plusieurs cycles de l'autre, avec le même nombre d'étages (et donc de cycles) pour toutes. Mais non seulement c'est possible, mais en plus, cela peut se faire de deux manières.
En pratique, il y a deux solutions, pour les pipelines fixes. Avec la première, toutes les instructions s'exécutent en un cycle d'horloge, pas un de plus, pas un de moins. Les opérations arithmétiques sont exécutées en un seul cycle d'horloge dans l'ALU entière, y compris les instructions "complexes" comme les multiplications ou divisions. Les accès mémoire aussi se font aussi en un seul cycle d'horloge, sauf en cas de défaut de cache. les pipelines de ce type seront appelés des '''pipelines 1-cycle'''. Il s'agit d'un cas fortement irréaliste, aucun processeur avec un pipeline ne fait dans le monde réel.
Une autre solution accepte les instructions multi-cycles. Et parmi ces instructions, l'une d'entre elle sera la plus lente de toute. Le processeur va se caler sur celle-ci. Par exemple, imaginons que l'instruction la plus lente prenne 15 cycles d'horloge, pour s'exécuter dans l'ALU entière. Son exécution sera pipelinée, de manière à se faire sur 15 étages. De plus, toutes les instructions feront 15 cycles pour s'exécuter, elles prendront 15 étages pour. Quitte à ce que certains étages ne fassent rien. Par exemple, l'addition pourra se faire en deux cycles d'horloge, mais les 13 restants ne feront rien. Nous verrons plus loin dans ce chapitre comment cette solution s'implémente, elle est surprenamment utile.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=2.5|Processeur à pipeline fixe qui gére des instructions multicycles]]
Enfin, il existe des '''pipelines de longueur variables''', qui utilisent un nombre d'étages différents selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Typiquement, toutes les instructions partagent lse premiers étages, qui chargent et décodent les instructions, mais le chemin de donnée est lui segmenté en plusieurs pipelines distincts, un par unité de calcul. Il y a ainsi un pipeline pour l'ALU entière, un autre pour le multiplieur entier, un autre pour la FPU, un autre pour l'unité mémoire, etc.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=2.5|Pipeline avec un nomrbe variable d'étages par instructions]]
Nous les appellerons des '''pipelines variables''', pour simplifier les explications. Ils ont l'air plus intuitifs que les pipelines fixes, sont aussi plus flexibles, mais ils sont nettement plus complexes que les pipelines fixes. De nombreuses subtilités surviennent sur ces pipelines variables, et c'est ce qui explique que nous ne les verrons pas avant facilement trois-quatre chapitres. Nous allons d'abord voir le fonctionnement des pipelines de longueur fixe, avant d'étudier les pipelines variables.
===Les pipelines synchrones et asynchrones===
Il faut que les instructions progressent d'un étage à un autre au rythme adéquat. Pour cela, la plupart des pipelines intercalent des registres entre les étages, pour les isoler mais aussi pour synchroniser leurs échanges. Les registres en question seront appelés des '''registres de pipeline''' dans ce qui suit. Un registre de pipeline s'intercale entre deux étages consécutifs. L'étage précédant ce registre écrit son résultat dedans, l'étage suivant lit son contenu quand c'est son tour, généralement au cycle d'horloge suivant.
La présence de registres aide grandement à implémenter de quoi commander le pipeline. Les transferts entre étages du pipeline peuvent être synchronisés par une horloge, ou de manière asynchrone.
[[File:Pipeline sync-async.svg|centre|vignette|upright=1.5|Pipeline synchrone et asynchrone.]]
Si le pipeline est synchronisé sur l'horloge du processeur, on parle de '''pipeline synchrone'''. Chaque étage met un cycle d'horloge pour effectuer son travail, à savoir, lire le contenu du registre qui le relie à l'étape précédente et déduire le résultat à écrire dans le registre suivant. Ce sont ces pipelines que l'on trouve dans les processeurs Intel et AMD les plus récents.
[[File:Pipeline buffered synchrone.png|centre|vignette|upright=2.0|Pipeline buffered synchrone]]
Sur d'autres pipelines, il n'y a pas d'horloge pour synchroniser les transferts entre étages, qui se font via un « bus » asynchrone. On parle de '''pipeline asynchrone'''. La synchronisation des échanges entre deux étages se fait grâce à un signal de requête et un signal acquittement. Le signal de requête REQ indique que l'étage précédent a terminé son travail et qu'on peut lire son résultat. Le signal d'acquittement signifie que l'étage destinataire a pris en compte la donnée transmise. Les signaux de commande de ces pipelines asynchrones peuvent se créer facilement avec des portes C.
[[File:Micropipeline-structure.svg|centre|vignette|upright=2.0|Micropipeline-structure]]
Les pipelines n'utilisant pas de registres sont assez rares, mais ils existent, ce sont les ''pipelines à vague''. Ils se contentent d'enchainer des étages les uns à la suite des autres, et laissent les données se propager librement d'un étage à l'autre. Ils jouent sur un timing très précis pour propager les données entre étages au bon timing, de manière à ce que le tout fonctionne comme un pipeline. Leur conception est cependant très complexe, ce qui fait qu'ils ne sont jamais utilisés en pratique. La règle est clairement de séparer les étages dans des circuits séparés, interfacés par des registres.
==La séparation des étages de pipeline==
Découper un processeur en pipeline peut se faire de différentes manières, le nombre et la fonction des étages variant fortement d'un processeur à l'autre. Dans ce qui suit, nous allons voir quelques pipelines simples, faciles à étudier. Nous allons partir d'un processeur sans pipeline, et allons séparer les unités dans plusieurs étages. Le processeur de départ est très simple, nous allons supposer que son chemin de données contient une ALU entière, une unité mémoire et le banc de registres. Nous ajouterons d'autres unités de calcul, comme une FPU ou un multiplieur entier, plus tard.
===Le pipeline Fetch-Exec===
En guise de premier exemple, nous allons prendre le '''pipeline ''Fetch-Execute'''''. IL a seulement deux étages : un qui charge et décode l'instruction, l'autre qui exécute l'instruction. En clair, il sépare le séquenceur et le chemin de données dans deux étages séparés. Il s'implémente en séparant les deux étages par un registre, qui mémorise les signaux de commande en sortie du décodeur d'instruction. Les deux étages sont respectivement nommés ''Fetch'' et ''Exec'', ce qui donne son nom au pipeline. Il s'agit du pipeline le plus simple qui soit, mais il a pourtant été utilisé sur les processeurs Atmel AVR, ainsi que les processeurs PIC.
[[File:The Fetch-Execute Cycle.svg|centre|vignette|upright=2|The Fetch-Execute Cycle]]
Un problème avec ce pipeline est que les deux étages peuvent parfois faire un accès mémoire en même temps. Le pipeline doit lire une instruction à chaque cycle d'horloge, quelle que soit l'instruction. Si l'étape d'''Exec'' exécute un accès mémoire pour lire/écrire une donnée, le processeur fait deux accès concurrents : un pour charger l'instruction, un autre pour lire/écrire la donnée. Il s'agit techniquement d'une situation de dépendance structurelle, mais passons.
La solution simple la plus simple est de passer à une architecture Harvard, avec deux mémoires séparées. Pour un processeur avec un cache, cela revient à utiliser un cache d'instruction séparé du cache de données, ce qui garantit que les deux étages ne se marcheront plus dessus et accéderont chacun à leur cache dédié. Une solution alternative utiliser un cache unique multiport, mais l'essentiel est qu'on puisse lire une instruction et accéder à une donnée en même temps. Précisons que tous les pipelines ont ce problème, sans exception ! Tous les pipelines que nous verrons dans ce cours font face au même problème et la même solution est appliquée.
Le pipeline précédent est de loin le plus simple. Tous les pipelines qui existent ne sont qu'une amélioration de celui-ci. Par amélioration, on veut dire que l'étage de ''Fetch'' est découpé en plusieurs sous-étages, idem avec l'étage d'''Exec''. Concrètement, le processeur est découpé en deux : un '''''front-end''''' qui charge et décode les instructions, un '''''back-end''''' qui exécute les micro-opérations. La séparation entre ''front-end'' et ''back-end'' colle presque parfaitement à la séparation entre séquenceur et chemin de données, et ce n'est pas un hasard. Elle est cruciale, et elle sera la ligne rouge des 10 chapitres qui vont suivre. Découper le séquenceur ne se fait pas de la même manière que le découpage du chemin de données. Nous allons voir les deux séparément, en commençant par le découpage du séquenceur.
===Le pipelining du séquenceur===
Le '''pipeline ''Fetch-Decode-Exec''''' sépare l'unité de chargement de l'unité de décodage d'instruction dans deux étages séparés. Le résultat est un pipeline à trois étages : le premier étage correspond au calcul du ''program counter'' et à l'accès au cache, le second au décodage de l’instruction, le troisième à l’exécution de l'instruction par le chemin de données. Ces trois étapes sont souvent désignées sous les noms de ''Fetch'', ''Decode'' et ''Exec'', termes que nous utiliserons dans la suite de ce cours. Encore une fois, ils donnent leur nom au pipeline. Le pipeline est donc le suivant :
* ''Fetch'' : chargement de l'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Bien que très simple, le pipeline ''Fetch-Decode-Exec'' a été utilisé dans des processeurs commerciaux grand public. Par exemple, les tout premiers processeurs ARM jusqu'aux processeurs ARM7 utilisaient un pipeline ''Fetch-Decode-Exec''.
L’implémentation sépare l'unité de chargement et le décodeur d'instruction avec un registre. Pour cela, on profite du registre d'instruction, qui était déjà entre l'étage de chargement et l'étage de décodage. L'implémentation est donc terriblement simple.
[[File:Pipeline à trois étages.png|centre|vignette|upright=2|Pipeline à trois étages]]
Il est possible de scinder l'étape de chargement en deux étapes séparées : la mise à jour du ''program counter'' et l'accès au cache d'instruction. La première étape ne fait pas que calculer le ''program counter'', mais gère aussi les branchements au sens large. Elle incorpore des techniques de prédictions de branchement que nous verrons dans le chapitre suivant. En tout cas, elle fait suffisamment de travail pour avoir un étage dédié, le travail prenant facilement un cycle d'horloge complet. Le pipeline devient alors le suivant :
* ''PC Update'' : mise à jour du ''program counter'' ;
* ''Fetch'' : accès au cache d'instruction ;
* ''Decode'' : décodage de l'instruction ;
* ''Exec'' : exécute l'instruction.
Précisons cependant une chose importante : la gestion des branchements se fait dans plusieurs étages. Le calcul des conditions est réalisé dans l'unité de calcul, donc dans l'étage d'''Exec''. Mais une fois l'adresse de destination connue, celle-ci doit être écrite dans le ''program counter'' , qui est lui dans l'étage de ''PC Update''.
[[File:Pipeline PC-Fetch-Decode-Exec.png|centre|vignette|upright=2.5|Pipeline PC-Fetch-Decode-Exec]]
Les processeurs modernes implémentent de nombreuses optimisations, qui partent du principe que le calcul du ''program counter'', l'accès au cache d'instruction, et le décodage sont trois étapes séparées. Mais nous verrons cela en temps voulu, dans le chapitre nommé "Les optimisations du chargement des instructions". Pour le moment, retenez que le début du pipeline peut être scindé en trois étapes nommées PC, ''Fetch'' et ''Decode'', qui peuvent elles-mêmes être séparées en sous-étapes pipelinées.
===Le pipelining du chemin de données===
Les pipelines précédents sont très simples à comprendre, car ils collent avec la séparation entre ''Program counter'', unité de chargement, décodage et chemin de données, qui nous est familière. Les pipelines qui vont suivre pipelinent leur chemin de données, d'une manière qui est moins intuitive. Autant découper le séquenceur en plusieurs circuits séparés est simple, autant découper le chemin de données ne l'est pas. Le chemin de données contient au minimum une unité de calcul, un banc de registres et l'unité mémoire (et un paquet d'interconnexion qu'on met sous le tapis). Reste à voir comment les pipeliner.
Pour simplifier, nous allons prendre le cas d'un processeur RISC, pour éliminer les instructions ''load-op'', qui sont particulièrement problématiques avec un pipeline. Vu que le processeur a une architecture LOAD/STORE, les instructions d'accès mémoire vont dans l'unité mémoire, les instructions de calcul vont dans l'ALU entière. Il parait donc naturel de mettre les deux dans un même étage de pipeline, qu'on appellera l''''étage d’exécution'''. L'étage d'exécution utilise donc soit l'une, soit l'autre, pas les deux.
L'étage d'exécution communique avec le banc de registre : pour récupérer les opérandes lues, pour enregistrer le résultat d'un calcul, ou la donnée lue par une lecture. Intuitivement, on se dit qu'il faudrait un étage pour lire dans le banc de registre et un autre pour écrire dedans. Et cela implique que le banc de registre est multiport, avec des ports de lecture et d'écriture séparés.
Le résultat est une organisation en trois étages :
* Chargement d’opérandes : lecture des opérandes depuis les registres.
* Exécution : exécution de l'instruction dans l'unité de calcul ou l'unité mémoire.
* Enregistrement : écriture du résultat dans les registres.
[[File:Pipeline à 7 étages, avec mode d'adressae immédiat et absolu gérés.png|centre|vignette|upright=2.0|Pipeline à 7 étages naïf.]]
Le pipeline précédent met l'unité mémoire et l'ALU entière en parallèle dans l'étage d'exécution. Soit l'une est utilisée, soit c'est l'autre. En clair, on met plusieurs unités en parallèle dans le même étage d'exécution. Et il est possible de faire pareil avec d'autres unités. Par exemple, on peut mettre le ''barrel shifter'' en parallèle de l'ALU entière et de l'unité mémoire. Les décalages iront dans le ''barrel shifter'', les autres opérations entières dans l'ALU entière, et les instructions LOAD/STORE dans l'unité mémoire.
Une autre possibilité, complémentaire des précédentes, est d'utiliser une unité de calcul dédiée aux branchements, qui est placée dans l'étage d'exécution. L''''unité de branchements''' est la seule à être reliée au séquenceur, l'unité de calcul normale ne l'est pas. Cela simplifie grandement la conception du processeur, sans impact sur les performances.
[[File:Pipeline de longueur fixe avec unités de calcul en parallèle.png|centre|vignette|upright=2|Pipeline de longueur fixe avec unités de calcul en parallèle]]
Une subtilité avec un pipeline de ce genre est que les instructions ont des besoins différents, mais qu'on les force à rentrer dans un pipeline qui a exactement N étages. La conséquence inévitable est que certains étages de pipeline leurs sont inutiles. Et le mieux, pour s'en rendre compte, et de regarder quelques exemples.
Les instructions MOV de copie entre registre n'utilisent ni l'unité de calcul ni l'unité mémoire. Elles lisent un registre et le recopient directement dans le registre destination? Seul le banc de registre est utilisé, l'étage d’exécution ne sert pour ainsi dire à rien. Une solution pour implémenter ces instructions est que l'ALU entière incorpore l'opération ''pass through'' qui recopie une entrée sur sa sortie. Les instructions MOV de copie inter-registres passent donc par l'ALU entière, mais celle-ci effectue donc un "NOP", elle ne fait rien. La conséquence est que l'étage d'exécution ne fait rien, mais l'instruction MOV quand même à travers celui-ci.
[[File:Inactivation d'un étage de pipeline avec des multiplexeurs.png|centre|vignette|upright=1.5|Inactivation d'un étage de pipeline avec des multiplexeurs]]
Le dernier étage enregistre quelque chose dans les registres : soit la donnée lue en mémoire, soit le résultat fournit par l'ALU. Il regroupe le port d'écriture du banc de registre, et quelques MUX. Et l'instruction STORE ne l'utilise pas, vu qu'elle n'a rien à enregistrer dans les registres. Pareil avec les branchements : ils n'enregistrent rien dans les registres, ils altèrent uniquement le ''program counter''. Cependant, les branchements et STORE passent quand même par cet étage, elles n'ont pas le choix. Simplement, elles ne font rien lors de cet étage.
Les branchements inconditionnels directs sont eux encore plus problématiques : ils sont exécutés par le décodeur d'instruction. Qui dit branchement inconditionnel dit que l'on n'a pas besoin de tester de conditions, donc pas besoin d'utiliser l'ALU entière, ni l'unité de branchement. De plus, qui dit direct dit que l'adresse de destination est encodée dans le branchement, le décodeur peut l'extraire sans lire les registres. Et évidemment, pas besoin d'enregistrer quoique ce soit dans les registres. Les étages du chemin de données sont totalement inutilisés.
Pour exécuter un tel branchement, l'unité de décodage envoie un NOP dans le chemin de données. En clair : elle dit aux étages de ne rien faire pendant un cycle d'horloge. Un tel NOP n'est cependant pas une instruction, mais une micro-opération NOP, interne au pipeline, qui a été générée lors du décodage. Nous verrons que de tels NOP sont utilisés pour bien d'autres choses. Aussi, je leur donner le nom de '''NOP de pipeline''', terme de mon invention.
Ceci étant dit, passons à tout autre chose. Le pipeline précédent met en parallèle ALU entière et unité mémoire. Et cela pose un problème pour gérer certains modes d'adressages, qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne peuvent pas être fait dans l'ALU entière, vu qu'elle est en parallèle de l'unité mémoire, pas avant elle. La seule solution est d'utiliser une unité de calcul d'adresse dédiée, placée avant l'unité mémoire. L'unité de calcul d'adresse est très simple, plus économe en circuit et plus rapide qu'une ALU entière vraie. Il y a donc une petite duplication de circuit, mais elle est nécessaire.
Une solution alternative fait faire les calcul d'adresse dans l'ALU entière. Le chemin de données est alors découpé en quatre étapes :
* ''Read Operands'' (RO) : lecture des opérandes dans les registres ;
* ''Execution'' (EXEC) : exécution d'un calcul dans l'unité de calcul ;
* ''Memory'' (MEM) : accès mémoire dans l'unité mémoire, facultatif ;
* ''Writeback'' (WB) : enregistrement d'une donnée dans les registres.
[[File:Pipeline à 7 étages naïf.png|centre|vignette|upright=2|Pipeline à 7 étages naïf]]
Un problème est que l'étage ''Memory'' (MEM) n'est pas utilisé pour les instructions de calcul, les branchements et toutes les instructions à l'exception de LOAD et STORE. Le problème précédent, à savoir des étages qui ne font rien, est là bien plus visible, plus fréquent et donc plus génant.
Et au-delà de ça, même pour les instructions LOAD et STORE, il y a des étages inutilisés. Par exemple, avec le mode d'adressage indirect à registre, il n'y a pas de calcul d'adresse, l'étage d'exécution est inutilisé. Et c'est encore pire avec le mode d'adressage absolu, vu que l'adresse est fournie par le séquenceur. Le banc de registre n'est pas utilisé non plus, l'étage ''Read Operands'' est donc inutile !
Et pourtant, malgré ses défauts, nous verrons qu'une variante de ce pipeline a été utilisée sur des processeurs commerciaux.
===La propagation des signaux de commande dans le pipeline===
Découper le chemin de données demande de placer des registres de pipeline au bon endroit. Les registres propagent les données adéquates, mais aussi les signaux de commande générés par le décodeur, qui configurent le chemin de données. En effet, relier l'unité de décodage aux circuits incriminés directement ne marcherait pas. Les signaux de commande arriveraient immédiatement aux circuits, alors que l'instruction n'a pas encore atteint ces étages ! Les signaux de commande doivent traverser le pipeline et être consommés par les étages adéquats, et doivent donc être propagé d'étage en étage, via les registres de pipeline. Cette méthode sera utilisée sur tous les pipelines où le chemin de données contient plusieurs étages.
[[File:Propagation des signaux de commande dans un pipeline à 7 étages.png|centre|vignette|upright=2.0|Propagation des signaux de commande dans un pipeline à 7 étages.]]
Il faut noter que quand on parle de signaux de commande, on parle dans un sens très large. Et cela inclus les constantes immédiates du mode d'adressage immédiat. les constantes sont fournies par le séquenceur et sont propagées d'étage en étage jusqu'à atteindre l'étage avec l'unité de calcul. Il en est de même pour le mode d'adressage absolu, qui fournit une adresse mémoire dans l'instruction. L'adresse est extraite par le décodeur d'instruction, propagée dans le pipeline jusqu'à atteindre l'unité mémoire.
[[File:Modes d'adressages absoluss et immédiats avec un pipeline.png|centre|vignette|upright=2.0|Modes d'adressages absoluss et immédiats avec un pipeline]]
==Les ''super-pipelines'' : des pipelines subdivisés==
Le pipeline vu juste avant est conçu pour être logiques, avec une séparation entre chargement/décodage/exécution/accès au registre qui est relativement intuitive. Le pipeline précédent est un bon point de départ, qu'on peut améliorer. Cependant, cette implémentation réelle n'aurait pas des performances optimales en situation réelle. La raison est que les étages du pipeline ne sont pas équilibrés. Voyons ce que ça veut dire.
Les étages de pipeline ne font pas la même chose. Prenons par exemple un pipeline basique, de type ''Fetch-Decode-Exec''. Les trois étages ne mettent pas le même temps pour faire leur travail. Pour donner un exemple pas fait pour être réaliste, charger une instruction depuis le cache prend 16 µs, la décoder prend 12 µs pour le décodeur, et l'exécuter prend 8 µs. Sans optimisations, la durée d'un cycle d'horloge devra se caler sur l'étage le plus lent. Nous l'appellerons l''''étage limitant''', terme qui est assez parlent.
Les étages limitants sont souvent ceux qui demandent de faire des accès mémoire. Ils sont exactement deux : l'étage MEM qui accède au cache de données, mais aussi l'étage ''Fetch'' qui charge une instruction depuis le cache d'instruction. Les deux caches L1 ont un temps d'accès similaire, ce qui fait qu'ils sont tous deux des étages limitants. Cependant, ce n'est pas toujours le cas, il arrive aussi que les étages limitants soient l'étage de décodage, ou l'étage d'exécution.
La solution la plus simple est de '''se caler sur l'étage limitant''', quitte à réorganiser le pipeline. Pour se caler sur l'étage limitant, il est possible de fusionner des étages vraiment rapides. Par exemple, imaginons que l'étage limitant fait 50 µs, qu'un calcul dans l'ALU fait 10 µs, et lire/écrire les registres en fait 15. Il est possible de fusionner lecture des opérandes et exécution dans un seul étage. Et l'exemple se généralise assez bien : il est intéressant de fusionner deux étages consécutifs pour obtenir un étage aussi rapide que l'étage limitant. L'inconvénient est que cela réduit le nombre d'étages, et donc le nombre d'instructions en vol.
[[File:Pipeline à 6 étages simplifié.png|centre|vignette|upright=2|Pipeline à 5 étages naïf]]
Mais il est possible de pipeliner encore plus le processeur, sans se caler sur l'étage limitant. L'idée est de subdiviser l'étage d'accès au cache, l'étage de décodage, voire l'étage d’exécution. Par exemple, l'étage de décodage devient alors deux étages séparés, voire trois. La technique porte le nom de '''super-pipeline'''. Elle permet d'augmenter le nombre d'étages, sans foncièrement changer les circuits du processeur. Il suffit de mettre des registres de pipeline au bon endroit dans le décodeur, l'ALU entière, les caches L1. En général, un super-pipeline a environ une dizaine d'étages, parfois plus, alors qu'un pipeline "classique" a moins de 7-8 étages.
===L'accès aux caches L1 est lent, mais se pipeline===
Dans l'exemple précédent, l'accès au cache d'instruction ne prend qu'un seul cycle d'horloge. Mais sur les processeurs modernes, l'accès au cache d'instruction prend deux à trois cycles. Et ce n'est pas incompatible avec la présence d'un pipeline, à condition que le cache lui aussi soit pipeliné. En clair, la solution est de '''pipeliner les caches L1'''. Lire une instruction se fait alors en plusieurs étapes.
Nous verrons comment pipeliner le cache dans un chapitre dédié nommé "Le parallélisme mémoire au niveau du cache". Mais pour le moment, nous allons donner un exemple assez basique. On suppose que le cache est un ''phased'' cache, où les ''tags'' et les données sont dans deux SRAM séparées. Un tel cache peut être pipeliné pour avoir un accès en deux cycles : le premier cycle vérifie les ''tags'', le second accède aux données.
[[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]]
Un exemple classique est celui des premiers processeurs MIPS, précisément le MIPS R4000. Son pipeline était un pipeline RISC classique, dans lequel les étages FETCH et MEM étaient pipelinés. Concrétement, le pipeline du MIPS R4000 utilisait deux étages pour lire une instruction dans le cache d’instruction, trois pour lire dans le cache L1 de données. le résultat était un pipeline à 8 étages, qui est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! colspan="2" | Instruction fetch !! Decocage !! Execution !! colspan="2" | Accès cache L1 !! Vérification TLB !! ''Writeback''
|-
| ''Fetch'' 1 || ''Fetch'' 2 || ''Decode'' + ''Read Operands'' || ''Exec'' || ''Data Fetch'' 1 || ''Data Fetch'' 1 || ''Tag Check'' || ''Writeback''
|}
Les premiers processeurs RISC faisaient en sorte qu'un accès aux caches L1 se fasse en un cycle d'horloge. Les premiers processeurs x86 étaient de ce type, les derniers étant la micro-architectures K5 d'AMD et le premier Pentium. La microarchitecture K6 d'AMD a pipeliné son cache, idem pour la microarchitectire P6 du Pentium 2 et 3. Utiliser un cache 1-cycle est donc une solution parfaitement viable, qui n'est abandonnée que quand le besoin de performance se fait sentir.
===Les décodeurs sont lents sur les CPU CISC, mais ils se pipelinent===
L'accès au cache est l'étage limitant sur les processeurs RISC. Pour les processeurs CISC, les choses sont parfois différentes. L'étage de décodage est lui aussi assez complexe, vu qu'il doit gérer des instructions de taille variable. Un décodeur CISC devient rapidement l'étage limitant, surtout si les caches L1 sont pipelinés. A problème similaire, solution similaire. Il est possible de '''pipeliner le décodeur d'instruction''', à savoir le scinder en plusieurs circuits consécutifs, séparés par des registres de pipeline. La scission peut se faire à des "moments de décodage" différents,.
Une scission assez logique sépare la gestion des instructions de taille variable du décodage lui-même. La gestion des instructions de taille variable implique d'utiliser des ''barrel shifter'' et des opérations de masquage, qui sont couteux en circuits et assez "lents". Le décodage proprement dit vient après. Et les deux peuvent être séparées. Un premier étage de décodage transforme les instructions de taille variable en "instructions de taille fixe", le second étage décode ces dernières. les deux étages de décodage sont souvent appelés les '''étages de pré-décodage''' et l''''étage de décodage''' proprement dit.
: Le terme pré-décodage ne doit pas être confondu avec le pré-décodage dans le cache d’instruction, qu'on verra dans quelques chapitres.
Mais la scission peut être encore plus profonde. Le décodage lui-même peut être découpés en plusieurs étapes, qui sont elles-mêmes pipelinées. Mais nous ne pouvons pas vraiment en dire plus, je ne peux pas donner de généralités là-dessus, les détails dépendent vraiment du jeu d'instruction et de l'implémentation du décodeur d'instructions.
Les processeurs modernes ont un pipeline de 8-20 étages, dont au moins un étage pour le calcul du ''program counter'' et 2 à 4 cycles pour l'accès au cache d'instruction, et tout autant de cycles pour le décodage. Et il est même possible de pipeliner l'étape de calcul du ''program counter''. Pour donner un exemple, le processeur Pentium Pro avait un pipeline de 14 étages, dont 7 étaient liés au décodage/chargement : deux étages pour le calcul du ''program counter'', trois pour l'accès au cache d'instruction, deux pour le décodage des instructions.
===Les instructions multi-cycles et l'ALU entière===
Enfin, il est possible de pipeliner l'unité de calcul. Mais nous n'allons pas le faire de façon naïve, nous allons ruser pour gérer des instructions comme les multiplications et les divisions assez simplement. Et comprendre pourquoi, faisons une remarque : les pipelines précédents ont un étage d'exécution unique, ce qui implique que toutes les instructions se font en un seul cycle d'horloge. Plus précisément, un calcul dans l'ALU se fait en un cycle d’horloge, on ne compte pas la lecture et l’écriture des registres.
Mais autant c'est bien adapté pour les additions/soustractions, décalages et comparaisons, autant ce n'est pas le cas pour les multiplications et les divisions. Idéalement, il faudrait ajouter de quoi supporter des '''instructions multi-cycles''' au pipeline, à savoir des instructions qui mettent plusieurs cycles pour s'exécuter. Et justement, pipeliner l'unité de calcul permet de supporter ces instructions très facilement.
Les instructions multi-cycles sont le plus souvent des multiplications entières, des divisions, ou des opérations flottantes. En conséquence, nous allons ajouter un multiplieur entier et une FPU dans le pipeline, dans l'étage d'exécution. Mais cela ne suffit pas, il faut modifier plus profondément le pipeline processeur. Il y a plusieurs implémentations possibles, mais nous n'allons pas toutes les voir. Deux chapitres entiers seront dédiés à cette problématique des instructions multi-cycles.
L'implémentation la plus simple utilise plusieurs étages d'exécution consécutifs. Toutes les instructions mettent alors le même nombre de cycles pour s’exécuter. Et là, pas le choix : on doit se caler sur l'instruction la plus lente. Par exemple, si le processeur a des additions qui se font en un cycle, des décalages qui se font en deux, et des multiplications qui se font en 8 cycles, alors il y aura 8 étages d’exécution. En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
De plus, pour avoir un fonctionnement en pipeline, les multiplieurs et FPU doivent être pipelinés. Par exemple, si une multiplication fait 4 cycles, le multiplieur doit être segmenté en 4 étages, chacun effectuant une partie de la multiplication. Rien de compliqué à cela. Par exemple, prenons un multiplieur 32 bits. Il effectue 32 additions successives, on peut le découper en quatre circuits qui font chacun 32/4 = 8 additions. Pipeliner la FPU est un peu plus compliqué, mais rien d'insurmontable.
[[File:Exemple avec une unité de multiplication totalement échelonnée.png|centre|vignette|upright=2|Multiplieur échelonné.]]
L'implémentation en question donne un pipeline simple. Mais il y a d'autres implémentations bien plus complexes, qui ont des couts en hardware différents. Pour les prochains chapitres, on va supposer que le processeur utilise un pipeline de ce type. Mais une fois dans le chapitre sur les pipelines ''in-order'', nous verrons que certains processeurs ne pipelinent pas les unités de multiplication, ni la FPU. Nous verrons aussi que qu'il y a des pipelines de longueur variables, où le nombre d'étages change suivant l'instruction. Mais laissons cela pour plus tard.
==Le pipeline RISC classique et ses variantes==
Les pipelines précédents sont conçus pour être relativement simples et pédagogiques. Mais la plupart des ''textbooks'' américains préfèrent utiliser un pipeline appelé le '''pipeline RISC classique'''. Il a été utilisé sur les premiers processeurs RISC : les CPU MIPS, SPARC, le 88000 et quelques autres. Des processeurs très influents et très populaires à leur époque.
Le pipeline RISC est présent dans presque tous les cours qui abordent le sujet du pipeline, que ce soit pour son intérêt historique, pour son apparente simplicité, ou par habitude pédagogique. Il est cependant loin d'être idéal au niveau pédagogique. Sa simplicité apparente est trompeuse et il a de nombreuses particularités qui ne sont pas évidentes à comprendre. Il a beau avoir un nombre limité d'étages, il est en réalité optimisé d'une manière assez subtiles, et ces optimisations ne sont pas intuitives. Je vais cependant aborder ce pipeline RISC classique rapidement, ce qui vous sera utile si jamais vous souhaitez consulter d'autres sources, des cours, des articles wiki ou autres.
Il s'agit d'un pipeline à 5 étages, qui met l'ALU et l'unité mémoire dans deux étages consécutifs. Les 5 étages sont les suivants :
* ''Instruction Fetch'' (IF) pour l'étage de ''Fetch'' ;
* ''Instruction Decode'' (ID) pour l'étage de décodage ;
* ''Execution'' (EXEC) pour l'étage d’exécution ;
* ''Memory'' (MEM) pour l'étage d'accès mémoire ;
* ''Writeback'' (WB) pour l'étage d'enregistrement dans les registres ;
Il est conçu pour une architecture RISC, avec un jeu d'instruction adapté pour simplifier le pipeline. Déjà, le processeur ne supporte pas les instructions multi-cycles, ce qui signifie que toutes les instructions se font en un cycle d'horloge. Exit donc les multiplications et divisions, qui sont absentes de ce processeur. Et c'est une simplification appréciable, car supporter des instructions multi-cycles complexifie grandement le pipelines. D'ailleurs, dans les deux-trois chapitres qui vont suivre, on mettra les instructions multi-cycles sous le tapis, par souci pédagogique.
Par contre, malgré ses simplifications, il gère les modes d'adressage indicés, et d'autres modes d'adressages qui demandent de faire des calculs d'adresse. Les calculs d'adresse ne sont pas réalisé dans une unité de calcul d'adresse séparée, mais dans l'ALU entière. Et c'est pour cela qu'il utilise un étage d'exécution et un étage mémoire séparé. Quelques cours utilisent parfois une version simplifiée à 4 étages, qui fusionne l'étage d'exécution et d'accès mémoire, qui est nettement plus simple à comprendre.
Bizarrement, ce pipeline n'utilise pas l'ALU entière pour faire les branchements. Elle utilise à la place des additionneurs séparés de l'ALU. Le principe est que cela permet d'exécuter les branchements en avance, ce qui a des avantages qui deviendront évident au chapitre suivant, quand nous parlerons des problèmes que causent les branchements pour un pipeline. Disons que pour une utilisation optimale du pipeline, les branchements doivent être exécutés le plus tôt possible.
===La lecture des opérandes se fait en parallèle du décodage===
L'unité de décodage est très simple, elle n'a pas de microcode. C'est nécessaire pour que le décodage se fasse en un seul cycle d'horloge. De plus, les processeurs RISC utilisent des encodages simples, faciles à décoder. Leurs instructions sont de taille fixes, elles étaient toutes codées sur 32 bits sur les processeurs avec ce pipeline.
Une subtilité est que la lecture des opérandes se fait en parallèle du décodage, et non après. C'est étrange, pas intuitif, et cela complexifie le pipeline. Les raisons derrière ce choix sont multiples. La première est que cela permet d'exécuter les branchements conditionnels en avance. La seconde est une histoire de dépendances structurelles que nous ne pouvons pas aborder ici.
Avec ce choix, l'équilibrage du pipeline n'est pas parfait, vu que l'étage de décodage fait beaucoup de choses. Il faut dire qu'on a fusionné l'étage de décodage avec l'étage ''Read Operand''. Le décodage devient donc beaucoup plus compliqué, car il faut extraire des numéros/noms de registre précocement lors du décodage.
Heureusement, le pipeline RISC classique était prévu initialement pour des processeurs MIPS, des processeurs RISC avec un encodage très simple. Le décodage des instructions était très simple, les opcodes étaient tous à la même place, les noms/numéros de registres aussi, on pouvait extraire l'opcode et le noms/numéros de registre au tout début du décodage. Les noms/numéros de registre étaient extraits simplement avec quelques multiplexeurs, puis envoyés au banc de registre pendant que l'unité de décodage finissait son travail. Mais notons que cela ne fonctionne que sur des processeurs RISC, et encore : il faut que l'encodage des instructions soit assez simple pour le permettre. C'est un détail qui ne se généralise pas.
===Les branchements, leur exécution et leur décodage===
L'étage de chargement dispose d'une première optimisation assez intéressante : il effectue la mise à jour du program counter en parallèle de l'accès mémoire. Le ''program counter'' est directement relié à l'entrée d'adresse du cache ou de la mémoire, son incrémenteur et les MUX pour les branchements sont situés après. Par contre, la gestion des branchements est assez complexe.
Les architectures MIPS peuvent faire un branchement qui s'exécute si deux registres sont égaux. Le calcul d'un branchement demande donc de comparer deux registres, puis de décider s'il faut brancher selon le résultat. Une première possibilité, la plus intuitive, place le comparateur dans l'étage d'exécution. D'autres implémentations utilisent l'unité de calcul entière pour faire la comparaison, ce qui est équivalent.
[[File:MIPS Architecture (Pipelined).svg|centre|vignette|upright=2.5|MIPS Architecture (Pipelinée)]]
Mais le pipeline RISC classique exécute la comparaison dans l'unité de décodage, juste après la lecture des registres ! L'avantage de faire ainsi n'est pas évident, mais le deviendra dans le chapitre suivant. Mais cela se fait au prix d'un étage de décodage bien chargé, le pipeline est très déséquilibré. LLe schéma précédent montre un exemple, mais sur une variante 4 étages du pipeline RISC classique.
[[File:Conditional Microprocessor 2.0 Pipeline Design 1.1.png|centre|vignette|upright=2.0|Le circuit noté JUMP est le comparateur qui sert à comparer deux registres. CF est un bit du registre d'état utilisé pour les branchements.]]
Un autre détail est que le calcul des branchements relatifs est réalisé dans l'unité de calcul. Quelques processeurs exécutaient les branchements conditionnels dans l'ALU entière. Pour rappel, les branchements relatifs ajoutent une constante au contenu du ''program counter'', afin de brancher X adresses après/avant l’instruction courante. Il y a donc une addition à faire, qui est réalisée par un additionneur dédié dans l'unité de chargement, du moins dans la majorité des cas. Mais le pipeline RISC préfère utiliser l'additionneur de l'ALU entière pour cela, pour diverses raisons. Le ''program counter'' est donc propagé dans le pipeline, et est connecté à un multiplexeur en entrée de l'ALU entière.
==La performance d'un pipeline==
Revenons un peu sur les pipelines synchrones. L'usage d'un pipeline augmente les performances, mais essayons de comprendre pourquoi. La raison est que l'on peut exécuter plusieurs instructions en même temps. Mais il se pourrait que cela aie d'autres effets, par exemple sur le temps d’exécution des instructions ou la fréquence. Pour comprendre toutes les conséquences de l'usage d'un pipeline, le mieux est d'étudier l'impact du pipeline sur divers paramètres du processeur : fréquence, temps d’exécution, parallélisme d'instruction, et autres. Nous allons nous focaliser sur la fréquence, le temps d’exécution d'une instruction et le nombre d'instructions exécutées en parallèle.
===La performance théorique d'un pipeline idéal (approche simplifiée)===
Pour commencer, nous allons voir cas d'un pipeline idéal, c'est à dire que nous allons négliger le fait qu'il y a des registres entre les étages du pipeline. Ils ont un temps de propagation non-nul, et ont donc un effet sur la fréquence et sur la latence des instructions. Mais pour simplifier les calculs, nous allons négliger le temps de propagation des registres inter-étages.
Sur les processeurs réels, certains étages possèdent un chemin critique plus long que d'autres. On est alors obligé de se caler sur l'étage le plus lent, ce qui réduit quelque peu le gain. La durée d'un cycle d'horloge doit être supérieure au temps de propagation de l'étage le plus lent.
[[File:Pipelining réel d'un circuit.png|centre|vignette|upright=2.5|Pipelining hétérogène d'un circuit]]
Cependant, nous allons supposer que les étages sont assez bien équilibrés, de manière à avoir le même temps de propagation. Le pipeline qu'on va étudier est donc un cas irréaliste de pipeline, idéal. Prenons un processeur qui exécute toutes ses instructions en un seul cycle sur un processeur et découpons ce processeur en 5 étages équilibrés. Voici ce que l'on obtient :
[[File:Effet de l'usage d'un pipeline sur la fréquence d'un processeur.png|centre|vignette|upright=2.0|Effet de l'usage d'un pipeline sur la fréquence d'un processeur.]]
Le schéma précédent montre que la fréquence change entre les deux situations car le cycle d'horloge n'est pas déterminé par l'instruction entière, mais par le temps de propagation d'un étage. Et cette durée correspond à la durée d'un cycle, divisée par le nombre d'étages. On passe donc c'un processeur dont la fréquence est de :
: <math>f = {1 \over t}</math>, avec t le temps d’exécution d'une instruction (sans pipeline).
à un processeur de fréquence :
: <math>f_\text{pipeline} = \frac{1}{t \over N} = N \times f</math>, avec N le nombre d'étages.
On voit que la fréquence a été multipliée par le nombre d'étages avec un pipeline ! En clair, l'usage d'un pipeline permet donc de multiplier la fréquence par un coefficient plus ou moins proportionnel aux nombres d'étages.
Par contre, le temps d’exécution d'une instruction ne change pas avec ou sans pipeline ! Certes, la fréquence est multipliée par N. Mais une instruction qui mettait un cycle d'horloge pour s'exécuter en prend maintenant N. Les deux phénomènes se compensent exactement, le temps d'exécution d'une instruction reste le même. Si la fréquence est multipliée par N, c'est compensée par le fait que l'instruction prend maintenant N étages pour s’exécuter, et donc N cycles d'horloge.
Le gain en performance vient du fait que le pipeline exécute plusieurs instructions simultanées, au lieu d'une seule sans pipeline. Il y a précisément une instruction dans chaque étage maximum, ce qui multiple la puissance de calcul maximale par le nombre d'étages du pipeline. En clair, le pipeline ne réduit pas le temps d’exécution d'une instruction, mais augmente les performances en en exécutant plusieurs presque simultanément. Si la latence reste la même, le débit du processeur augmente.
Au passage, peut-être avez-vous pensé à utiliser l'équation vue dans le chapitre sur la performance d'un ordinateur :
: <math>\text{Temps d’exécution d'un programme} = N_{instructions} \times \frac{CPI}{f}</math>, avec f la fréquence, CPI le nombre de cycles moyen par instruction, et <math>N_{instructions}</math> le nombre d'instructions à exécuter.
Mais l'équation en question ne fonctionne que si on suppose que les instructions sont exécutées l'une après l'autre.
===La performance théorique d'un pipeline avec des registres inter-étages===
En théorie, le raisonnement précédent nous dit que le temps d’exécution d'une instruction est le même sans ou avec un pipeline. Cependant, il faut prendre en compte les registres intercalés entre étages du pipeline, qui ajoutent un petit peu de latence. Nous allons noter <math>\tau</math> le temps de propagation d'un de ces registres et refaire les calculs précédents, en tenant compte de ce terme. Le temps de parcours d'un étage devient :
: <math>t_\text{étage} = \frac{t}{N} + \tau</math>
La fréquence du processeur est l'inverse de <math>t_\text{étage}</math>, ce qui donne :
: <math>f = \frac{1}{\frac{t}{N} + \tau} = \frac{N}{t + \tau \times N}</math>
L'équation précédente nous dit que la fréquence du processeur avec un pipeline augmente, comparé à un processeur sans pipeline, mais pas autant que dans le cas idéal précédent. Elle n'est pas multipliée par le nombre d'étages, mais un peu en-dessous. Passer de 1 étage à 2 donne un gain en fréquence, mais passer de 20 à 40 aura un effet beaucoup plus faible. Les rendements sont rapidement décroissants, la durée d'un étage étant de plus en plus dominée par la latence des registres. On peut calculer le gain en comparant la fréquence avec un pipeline "parfait", où les registres sont négligeables :
: <math>\frac{f_\text{Pipeline avec registres}}{f_\text{Pipeline sans registres}} = \frac{1}{{N \tau \over t} + 1}</math>
Le résultat est inférieur à 1 du fait de l'influence des registres de pipeline. Le terme <math>{N \tau \over t}</math> correspond au pourcentage de temps passé à traverser les registres par rapport au temps de propagation sans les registres. Par exemple, si le temps de propagation des registres prend la moitié du temps de propagation, alors ce terme vaut 1 et la fréquence est divisée par deux.
===Le monde réel : l'exemple des processeurs Intel===
Les développements précédents sont très intéressants, mais il reste à voir ce que cela donne dans le monde réel. Pour cela, il faut prendre un échantillon de processeurs et comparer leur fréquence en fonction de la longueur de leur pipeline. Mais il faut tenir compte de la finesse de gravure, qui impacte fortement la fréquence du processeur et fausse les comparaisons. Il est possible de déterminer une une ''fréquence relative'', à savoir la fréquence à finesse de gravure égale, par rapport à un processeur de référence.
Et on s’aperçoit que cette fréquence relative est fortement reliée aux nombres d'étages du pipeline. Un processeur à haute fréquence relative a un pipeline plus long que ses concurrents de fréquence relative plus basse. La relation n'est pas proportionnelle, du fait des registres inter-étages et de l’hétérogénéité des latences intra-étage. Par exemple, regardons la fréquence relative des processeurs Intel d'avant le Pentium 4 (les seuls pour lesquels j'ai les données). Voici ce qu'on obtient :
[[File:Fréquences relatives processeurs Intel pré-Pentium 4.png|centre|vignette|upright=2|Fréquences relatives processeurs Intel pré-Pentium 4]]
Et comparons la longueur de leur pipeline :
{|class="wikitable"
|-
! Processeur
! Longueur du pipeline
|-
| 286
| rowspan="4" | 5
|-
| 386
|-
| 486
|-
| Pentium
|-
| Pentium 2/3
| 14
|-
| Pentium 4
| 31 à 20 selon les modèles
|}
On s’aperçoit qu'il n'y a pas proportionnalité exacte, mais que la tendance est bonne. La profondeur du pipeline a presque triplé au passage au Pentium 2/3, mais cela s'est traduit par une hausse de fréquence de seulement 50%. Pour le Pentium 4, sa fréquence a sextuplé par rapport aux premiers CPU Intel, mais la fréquence a été multipliée par seulement 2,5.
Avant les années 2000, la fréquence était un argument marketing puissant. Ce qui a poussé certains fabricants de processeurs à créer des processeurs avec un nombre d'étages élevé pour les faire fonctionner à très haute fréquence. C'est ce qu'a fait Intel avec le Pentium 4, dont le pipeline faisait 20 étages pour les Pentium 4 basés sur les architectures Willamette et Northwood, 31 étages pour ceux basés sur les architectures Prescott et Cedar Mill. L'approche a rapidement montré ces limites, poussant Intel à revenir à un nombre d'étages raisonnables pour les processeurs Core 2 Duo.
==Les exceptions précises==
Les processeurs avec un pipeline ont un petit problème avec des exceptions matérielles. Les exceptions stoppent l’exécution du programme en cours et effectuent un branchement vers une routine d'interruption. Mais avant que l'exception n'ait été détectée, le processeur a chargé des instructions dans le pipeline en avance. Lorsque l'exception est détectée, elle mémorise le ''program counter'' de la dernière instruction chargée, et reprendre à cet endroit là. La conséquence est que les exceptions sont décalées de quelques cycles par rapport à un processeur sans pipeline. On dit que les exceptions sont ''imprécises''.
[[File:Exception et pipeline.png|centre|vignette|upright=2|Exception détectée au 3ème étage d'un pipeline à 4 étage : deux instructions ont été chargées en avance.]]
Et ce décalage de quelques cycles d'horloge est problématique. La majeure partie des exceptions sont traitées comme suit : la routine de l'exception traite la situation, puis le processeur redémarre l'instruction fautive. Un exemple est le cas d'une instruction d'accès mémoire qui déclenche un défaut de page : le défaut de page déclenche une exception, la routine d'exception traite le problème en chargeant la page voulue en RAM, puis re-démarre l'instruction mémoire. Mais re-démarrer l'instruction fautive implique de reprendre l'exécution du programme juste après l'instruction qui a déclenché l'exception. Si des instructions ont été chargées/exécutées en avance, ce n'est pas le cas : on reprend plusieurs instructions après.
En soi, le processeur peut très bien ne rien faire contre ce problème, et utiliser des exceptions imprécises. Le problème peut être géré de manière purement logicielle. Au pire, cela peut poser quelques problèmes à quelques programmes comme les débuggers, même si les cas sont rares. Par contre, cela implique que l'on ne peut pas traiter les exceptions en re-démarrant l'instruction fautive. Et cela complexifie grandement la gestion des exceptions, le système d'exploitation est plus complexe, le cout logiciel est là. Pour régler ce petit problème, les concepteurs de processeur ont fait en sorte que le processeur gère lui-même exceptions et interruptions correctement. Ils permettent une gestion des '''exceptions précises'''.
===Le hardware pour les exceptions précises===
Supporter les exceptions précises demande des modifications assez mineures du processeur. L'idée est que les instructions chargées en avance sont juste annulées, elles n'enregistrent pas leur résultat dans les registres et n’accèdent pas à la mémoire. En effet, si une instruction n'enregistre pas son résultat, que ce soit dans les registres ou la RAM, c'est comme si elle n'avait rien fait. Si une exception a lieu, il suffit de ne pas enregistrer les résultats des instructions suivantes dans les registres, jusqu’à ce que toutes les instructions fautives aient quitté le pipeline.
Les exceptions précises sont gérées dans le dernier étage du pipeline, l'étage de ''writeback'', celui d'écriture dans les registres. Pour cela, tout étage fournit à chaque cycle un '''indicateur d'exception''', un groupe de quelques bits qui indiquent si une exception a eu lieu et laquelle le cas échéant. Ces indicateurs sont propagés dans le pipeline, ils passent à l'étage suivant à chaque cycle. Une fois arrivé à l'étage d’enregistrement, un circuit combinatoire vérifie ces bits pour détecter si une exception a été levée, et interdit l'écriture dans les registres en cas d'exception. Si les écritures sont interdites, elles le sont durant un certain nombre de cycles, dépendant de l'exception levée. On dit qu'on '''vide le pipeline''' des instructions fautives.
[[File:Propagation de l'indicateur d'exception.png|centre|vignette|upright=2|Propagation de l'indicateur d'exception.]]
Il faut aussi qu'une fois l'exception terminée, le programme reprenne là où il faut. Pour cela, il faut restaurer le ''program counter'' pour qu'il pointe vers l’instruction adéquate. Il s'agit de l'instruction qui a déclenché l'exception. Pour cela, même solution : le ''program counter'' est propagé dans le pipeline et est restauré en cas d'exception en prenant le ''program counter'' disponible à l'étage d'enregistrement.
Une utilisation "détournée" du système d'exceptions précise est la gestion des défauts de cache. Elle permet de ne pas bloquer le pipeline au moment même où un défaut de cache a lieu, mais retarde le blocage. L'idée est qu'un défaut de cache lève une exception matérielle, nommée ''cache fault''. Les instructions qui suivent la lecture/écriture fautive sont alors propagées dans le pipeline et sont annulées à la toute fin. Une fois le pipeline vidé, l'instruction fautive est re-exécutée une fois que le défaut de cache est terminé. En clair, on vide le pipeline avant de le re-démarrer. Un simple compteur est utilisé pour temporiser le re-démarrage du pipeline. Les exceptions précises sont aussi utilisées pour corriger des problèmes liés aux branchements, comme on le verra dans le chapitre suivant.
===Les écritures mémoire sont retardées avec des exceptions précises===
Il faut préciser une chose importante : toutes les écritures sont annulées, y compris les écritures en mémoire RAM, y compris celles dans le cache de données. Mais cela pose un problème : les instructions STORE sont censées être exécutées dans l'étage MEM ou Exec, soit avant l'étage de ''Writeback''. Notez bien le censé : ce n'est pas le cas avec les exceptions précises. En réalité, avec des exceptions précises, les instructions STORE font leur écriture dans l'étage de ''Writeback'', l'étage final.
Les écritures mémoire sont donc retardées jusqu'à l'étage de ''Writeback''. La mise en attente se fait grâce à une mémoire spécialisée, appelée la '''file d'écriture'''. Le terme "file" trahit le fait que c'est une mémoire FIFO, le caractère FIFO permettant de conserver l'ordre des écritures. L'idée est la suivante : l'étage MEM fait l'écriture dans la file d'écriture, puis l'étage de ''Writeback'' la met en attente. A l'étage de ''Writeback'', la file d'écriture exécute l'écriture dsans le cache. L'écriture est mise en attente dans l'étage MEM, et est finalisée dans l'étage de ''Writeback''. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente. Les écritures faites à tord sont donc annulées.
[[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|File d'écriture sur un pipeline fixe]]
Avec une file d'écriture, l'étage MEM s'occupe des lectures, et l'étage ''Writeback'' des écritures proprement dit. Ils font tous deux un accès au cache, ce qui est une dépendance structurelle. Une solution intuitive, utilise un cache de données multiport, avec un port de lecture séparé du port d'écriture. Le port de lecture est utilisé par l'étage MEM, alors que le port d'écriture est utilisé par l'étage de ''Writeback''.
: La file d'écriture font nous parlons ici ne doit pas être confondue avec le ''Write buffer'' présent dans la hiérarchie mémoire. Les deux sont traduit dans ce cours avec le terme file d'écriture, mais il s'agit de deux choses différentes.
La file d'écriture peut mettre en attente plusieurs écritures. Le nombre d'écriture à mettre en attente dépend du pipeline. Le nombre exact est égal au nombre d'étages situés entre l'étage MEM et l'étage ''Writeback'', plus 1. Par exemple, les pipelines précédents n'ont pas d'étages entre MEM et ''Writeback'', ce qui fait que la ''Write Queue'' n'a qu'une seule entrée. Il peut être remplacé par un simple registre, mais ce n'est que rarement fait. Mais de nombreux pipelines ont des étages entre les étages MEM et ''Writeback''. Par exemple, s'il y a 4 étages entre les deux, il faut que la file d'écriture puisse mettre en attente 5 écritures. Un autre point est que l'étage MEM peut lui-même prendre plusieurs cycles d'horloge, ce qui est possible si le cache est pipeliné. Dans ce cas, ce nombre de cycle s'ajoute au nombre d'entrées.
{|class="wikitable"
|-
! colspan="3" | Étages MEM !! colspan="4" | Étages autres !! Étage de ''Writeback''
|-
| MEM 1 || MEM 2 || MEM 3 || X || X || X || X || ''Writeback''
|-
! colspan="8" | File d'écriture
|-
| Entrée 1 || Entrée 2 || Entrée 3 || Entrée 4 || Entrée 5 || Entrée 6 || Entrée 7 || Entrée 8
|}
Pour résumer,
: <math>\text{Capacité de la file d'écriture} = \text{Nombre d'étages MEM} + \text{Nombre d'étages entre MEM et WB} + 1</math>
===Le contournement de la file d'écriture===
Les écritures sont donc retardées de plusieurs cycles, pour s'exécuter en fin de pipeline. Et il se peut que la donnée écrite soit lue pendant ce laps de temps. Il s'agit du cas où une instruction STORE est suivie par une instruction LOAD, et que les deux accèdent à la même adresse. Lire une donnée qu'on vient d'écrire n'est pas si rare, surtout sur les processeurs x86, qui ont peu de registres, et doivent parfois faire de la place pendant quelques cycles, avant de rapatrier des données dans les registres.
Vu que la lecture a lieu pendant la mise en attente, la donnée n'a pas été écrite dans le cache. Toute lecture dans le cache lira une donnée invalide. Mais il y a une solution assez évidente. L'idée est que les lectures consultent à la fois le cache et la file d'écriture. La file d'écriture doit cependant être modifiée pour fonctionner comme un mélange entre mémoire cache et mémoire FIFO.
Le cache envoie l'adresse à lire à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un ''succès de file d'écriture'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'' ou encore de '''réacheminement écriture-vers-lecture'''.
[[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le Translation Lookaside Buffer
| prevText=Le Translation Lookaside Buffer
| next=La prédiction de branchement
| nextText=La prédiction de branchement
}}
</noinclude>
7tthuqroqk4oclb912jqadybsu1gc9n
Fonctionnement d'un ordinateur/Les circuits de comparaison
0
69541
775252
749048
2026-10-13T22:31:19Z
Mewtow
31375
/* Le comparateur complet */
775252
wikitext
text/x-wiki
[[File:Comp 4 Bit.svg|vignette|Comparateur 4 Bits.]]
Les '''comparateurs''' sont des circuits qui permettent de comparer deux nombres, à savoir s'ils sont égaux, si l'un est supérieur à l'autre, ou inférieur, ou différent, etc. Il faut signaler que nous avons déjà vu comment vérifier qu'un nombre est égal à une constante dans le chapitre sur les circuits combinatoires, aussi nous ne reviendrons pas dessus.
==Le comparateur de 1 bit==
Pour commencer, nous allons voir un circuit qui prend deux bits comme opérande, et vérifie si le premier est inférieur, supérieur ou égal au second. Les deux bits d'opérandes sont appelés respectivement a et b, et le circuit vérifie si <math>a = b</math>, si <math>a > b</math> ou si <math>a < b</math>.
===La supériorité et l'infériorité===
En premier lieu, nous allons voir comment vérifier qu'un bit A est strictement supérieur ou inférieur à un bit B. Pour cela, le mieux est d'établir la table de vérité des deux comparaisons. La table de vérité est la suivante :
{| class="wikitable" style="text-align:center"
|-
! Entrée a
! Entrée b
! Sortie <
! Sortie >
|-
| 0 || 0 || 0 || 0
|-
| 0 || 1 || 1 || 0
|-
| 1 || 0 || 0 || 1
|-
| 1 || 1 || 0 || 0
|}
On obtient les équations suivantes :
* Sortie > : <math>\overline{a} . b</math>
* Sortie < : <math>a . \overline{b}</math>
Un tel circuit ne paye pas de mine, mais il permet d'encoder toutes les comparaisons possibles entre deux bits. Par exemple, nous n'avons pas besoin de circuit supplémentaire pour savoir si <math>a \leq b</math>. Par définition, <math>a \leq b</math> est l'inverse de <math>a > b</math>. Donc, on peut savoir si <math>a \leq b</math> en regardant la sortie <math>a > b</math> : elle est à 0 si c'est le cas. Et il en est de même avec <math>a \geq b</math> est l'inverse de <math>a < b</math>.
===L'égalité et l'inégalité===
Il est aussi possible de savoir si les deux bit d'entrée sont égaux. Par définition, si les deux bit opérande sont égaux, alors les deux conditions <math>a < b</math> et <math>a > b</math> sont fausses. Il s'agit des deux lignes de la table de vérité où les deux sorties sont à 0. Il suffit de combiner les deux résultats avec une porte NOR pour obtenir le bit d'égalité. Pour vérifier si deux bits sont différents, c'est le même principe. Si A et B sont différents, alors on a soit A>B, soit A<B. On a juste à combiner les deux sorties "A > B" et "A < B" avec une porte OU pour tester si une des deux conditions est respectée.
[[File:Comparateur 1 bit.png|centre|vignette|upright=2|Comparateur d'égalité 1 bit.]]
Les deux circuits précédents devraient vous dire quelque chose, si vous vous souvenez vraiment bien du cours sur les portes logiques. En effet, il s'agit respectivement de portes NXOR et de portes XOR ! Pour vous en convaincre, le mieux est d'établir la table de vérité des deux circuits, qui est donnée ci-dessous. On voit rapidement qu'il s'agit des tables de vérité des portes XOR et NXOR.
{|class="wikitable"
|-
!Entrée 1!!Entrée 2!!Egalité = !!Inégalité !=
|-
||0||0||0||1
|-
||0||1||1||0
|-
||1||0||1||0
|-
||1||1||0||1
|}
===La comparaison entre comparateur et soustracteurs===
Dans ce qui suit, nous partons du principe que l'égalité est testée avec une porte NXOR, et non avec les circuits vus précédemment, mais c'est seulement pour simplifier les schémas. Le circuit utilise trois portes logiques : une pour tester l'égalité, une pour la supériorité, une pour l'infériorité.
{|
|[[File:Magnitude Comparator.svg|vignette|Comparateur de magnitude 1 bit.]]
|[[File:Comparador 2bits.png|vignette|upright=1.5|Comparateur de magnitude 1 bit, alternatif.]]
|}
[[File:Half Subtractor Vektor.svg|vignette|Demi-soustracteur.]]
Il est intéressant de comparer ce circuit avec les circuits soustracteurs et additionneurs. Pour rappel, l'image ci-contre est le circuit qui fait une soustraction sur deux bits, un demi-soustracteur. Vous remarquerez qu'il ressemble beaucoup à un comparateur-1 bit. Précisément, un demi-soustracteur est équivalent à un comparateur 1 bit qui teste les deux conditions suivantes : <math>a < b</math>, <math>a \neq b</math>. Et avec ces deux conditions, on peut retrouver toutes les autres !
{| class="wikitable" style="text-align:center"
|-
! a < b
! <math>a \neq b</math>
!
!
|-
| 0 || 0 || a = b || rowspan="2" | <math>a \geq b</math>
|-
| 0 || 1 || a > b
|-
| 1 || 0 || Impossible
|-
| 1 || 1 || a < b
|}
Il y a donc un lien assez fort entre soustraction et comparaison. Et c'est normal, car une retenue est générée quand <math>a < b</math> ! D'ailleurs, dans les processeurs modernes, les comparateurs sont fabriqués en modifiant un soustracteur. Nous verrons comment dans la section adéquate.
==Les comparateurs spécialisés==
Dans cette section, nous allons voir des comparateurs assez simples, plus simples que les autres, qui ont une structure similaire. Ils sont composés d'une couche de comparateurs de 1 bits, suivi par une porte logique à plusieurs entrées. La porte logique combine les résultats de ces comparaisons individuelles et en déduit le bit de sortie. Les comparateurs que nous allons voir sont : un comparateur d'égalité qui vérifie si deux nombres sont égaux, et un comparateur qui vérifie si une opérande vaut zéro.
===Le comparateur avec zéro===
Le circuit que nous allons maintenant aborder ne compare pas deux nombres, mais vérifie si l'opérande d'entrée est nulle. Il fonctionne sur un principe simple : un nombre est nul si tous ses bits valent 0. La quasi-totalité des représentation binaire utilisent la valeur 0000...0000 pour encoder le zéro. Les entiers non-signés et en complément à deux sont dans ce cas, c'est la même chose en complément à un ou en signe-magnitude si on omet le bit de signe. La seule exception est la représentation ''one-hot'', et encore !
La solution la plus simple pour créer ce circuit est d'utiliser une porte NOR à plusieurs entrées. Par définition, la sortie d'une NOR vaut zéro si un seul bit de l'entrée est à 1 et 0 sinon, ce qui répond au cahier des charges.
[[File:Porte NOR utilisée comme comparateur avec zéro.png|centre|vignette|upright=2.0|Porte NOR utilisée comme comparateur avec zéro.]]
Il existe une autre possibilité strictement équivalente, qui inverse l'entrée avant de vérifier si tous les bits valent 1. Si l'entrée est nulle, tous les bits inversés valent tous 1, alors qu'une entrée non-nulle donnera au moins un bit à 0. Le circuit est donc conçu avec des portes NON pour inverser tous les bits, suivies d'une porte ET à plusieurs entrées qui vérifie si tous les bits sont à 1.
[[File:Comparateur avec zéro.jpg|centre|vignette|upright=2.0|Circuit compare si l'opérande d'entrée est nulle.]]
: Notons qu'en peut passer d'un circuit à l'autre en utilisant les lois de de Morgan.
===Le comparateur d'égalité===
Passons maintenant à un circuit qui compare deux nombres et vérifie s'ils sont identiques/différents. Les deux sont l'inverse l'un de l'autre, aussi nous allons nous concentrer sur le '''comparateur d'égalité'''. Intuitivement, on se dit que deux nombres sont égaux s'ils ont la même représentation en binaire, ils sont différents sinon. Mais cela ne marche pas pour toutes les représentations d'entiers signés. La raison est que certaines ont deux zéros : un zéro positif et un zéro négatif. Mais laissons ces représentations de côté pour le moment.
Le circuit qui vérifie si deux nombres sont égaux est très simple : il prend les bits de même poids des deux opérandes (ceux à la même position, sur la même colonne) et vérifie qu'ils sont égaux. Le circuit est donc composé d'une première couche de portes NXOR qui vérifie l'égalité des paires de bits, suivie par une porte logique qui combine les résultats des porte NXOR. La logique dit que la sortie vaut 1 si tous les bits de sortie des NXOR valent 1. La seconde couche est donc, par définition, une porte ET à plusieurs entrées.
[[File:Identity Comparator.svg|centre|vignette|upright=1|Comparateur d'égalité.]]
Une autre manière de concevoir ce circuit inverse la logique précédente. Au lieu de tester si les paires de bits sont égales, on va tester si elles sont différentes. La moindre différence entre deux bits entraîne l'inégalité. Pour tester la différence de deux bits, une porte XOR suffit. Le fait qu'une seule paire soit différente suffit à rendre les deux nombres inégaux. Dit autrement, si une porte XOR sort un 1, alors la sortie du comparateur d'égalité doit être de 0 : c'est le fonctionnement d'une porte NOR.
Voici une autre interprétation de ce circuit : le circuit compare un circuit XOR et un comparateur avec zéro. Il faut savoir que lorsqu'on XOR un nombre avec lui-même, le résultat est zéro. Et c'est le seul moyen d'obtenir un zéro comme résultat d'un XOR. Donc, les deux nombres sont XOR et un comparateur vérifie si le résultat vaut zéro. Le circuit final est donc un paquet de portes XOR, et un comparateur avec zéro.
: Notons qu'on peut passer d'un circuit à l'autre en utilisant la loi de de Morgan
Le circuit qui vérifie si deux nombres sont différents peut être construit à partir du circuit précédent, en remplacant la porte ET par une porte NAND (ou la porte NOR par une porte OU).
Il existe des '''comparateurs d'égalité sériel''', qui font la comparaison bit par bit, sur le même modèle que l'additionneur sériel. Nous aurons à utiliser ce circuit plusieurs fois dans la suite du cours, notamment dans le chapitre sur les mémoires associatives. Le circuit est très simple : la bascule n'a besoin que de mémoriser un seul bit, la comparaison d'égalité est réalisée par une porte NXOR, son résultat est combiné avec le contenu de la bascule avec une porte ET.
[[File:Comparateur d'égalité sériel.jpg|centre|vignette|upright=2|Comparateur d'égalité sériel.]]
==Les comparateurs basés sur un soustracteur==
Les comparateurs présents dans les ordinateurs modernes sont des soustracteurs modifiés. Une comparaison est implémentée avec une soustraction, dont on ne conserve pas le résultat. Une fois le résultat de la soustraction a - b connu, on regarde le signe du résultat, pour vérifier s'il est positif, nul ou négatif. Ce qui permet de tester respectivement la supériorité, l'égalité et l'infériorité. Les autres conditions s'en déduisent.
===La génération des conditions===
Plus haut, j'ai dit qu'un comparateur soustrait les deux opérandes et regardait si le résultat est positif, nul ou négatif. Il nous faut donc déterminer si le résultat est positif, nul ou négatif. Toute la difficulté est de déterminer si le résultat est positif, négatif ou nul. Pour le déterminer, on analyse quatre propriétés intéressantes du résultat : s'il vaut zéro ou non, son bit de signe, s'il génère une retenue sortante (débordement entier non-signé) et s'il génère un débordement entier en complément à deux. Ces quatre propriétés sont empaquetées dans 4 bits, appelés les 4 '''bits intermédiaires'''. En effectuant quelques opérations sur ces 4 bits intermédiaires, on peut déterminer toutes les conditions possibles : si la première opérande est supérieure à l'autre, si elle est égale, si elle est supérieure ou égale, etc.
Dans ce qui va suivre, nous allons noter les quatre bits intermédiaires comme suit :
* un bit S qui n'est autre que le bit de signe en complément à deux ;
* un bit Z qui indique si le résultat est nul ou non (1 pour un résultat nul, 0 sinon) ;
* un bit C qui n'est autre que la retenue sortante (1 pour un débordement d'entier non-signé, 0 sinon) ;
* un bit D qui indique un débordement d'entier signé (1 pour un débordement d'entier signé, 0 sinon).
Le comparateur est donc composé de trois sous-circuits : le soustracteur, un circuit qui calcule les 4 bits intermédiaires, puis un autre qui calcule les conditions. La génération des 4 bits intermédiaire demande peu de calculs. Déterminer si le résultat est nul demande juste d'utiliser un comparateur avec zéro. Le bit de signe est tout simplement le bit de poids fort. La génération des bits de débordement a été vue dans le chapitre sur les circuits d'addition et de soustraction, pas besoin de revenir dessus. Par contre, il est intéressant de voir comment les 4 bits intermédiaires sont utilisés pour générer les conditions voulues.
[[File:Comparateur fabriqué avec un soustracteur.png|centre|vignette|upright=1|Comparateur fabriqué avec un soustracteur.]]
Le bit Z indique que le résultat est nul, ce qui permet de détecter que deux opérandes sont égales. Déterminer si le résultat est nul ou non se fait de la même manière en complément à deux et avec des entiers non-signés, car il n'y a pas de zéro signé comme en signe-magnitude. Le bit de signe et les bits de débordement permettent de calculer la condition d'infériorité, à savoir si la première opérande est inférieure à la seconde ou non. Les autres conditions se déduisent en combinant ces deux bits. Et pour cela, on procède différemment entre le complément à deux et les opérandes non-signées.
Avec des opérandes non-signées, il n'y a pas de bit de signe. Pour déterminer si le résultat est négatif, il faut regarder si la soustraction génère une retenue sortante, un débordement entier non-signé. Si un débordement entier a lieu lors de la soustraction, le résultat est négatif : la première opérande est inférieure à la seconde. Si ce n'est pas le cas, alors la première opérande est supérieure ou égale à la seconde. En clair, le cas où "C = 1" implique que "A < B". Inversement, s'il vaut 0, alors on a "A >= B". Pour les conditions restantes, il suffit de combiner les deux résultats précédents avec le bit Z.
{|class="wikitable"
|+ Opérandes non-signées
|-
! Z
! C
!
! A < B
! A = B
|-
| 0
| 0
|
| 0
| 0
|-
| 0
| 1
|
| 1
| 0
|-
| 1
| 0
|
| 0
| 1
|-
| 1
| 1
|
| colspan="2" | Impossible
|}
Voyons maintenant le cas des opérandes en complément à deux. Avec ces opérandes, le bit de signe dit si le résultat est négatif ou non, mais ne distingue pas un résultat positif d'un résultat nul. Pour cela, on doit ajouter un comparateur avec zéro, qui vérifie si le résultat est nul ou non. La logique a l'air de marcher, mais il y a un petit problème : il faut tenir compte les débordements d'entier en complément à deux. Mais restons sur des opérandes non-signées.
Pour la comparaison en complément à deux, le principe est le même : on regarde si le résultat est positif, nul ou négatif. Il faut regarder le signe du résultat, mais aussi regarder les débordements d'entier (sous-entendu, un débordement en complément à deux). Et c'est intuitif quand on sait qu'un débordement d'entier en complément à deux correspond au cas où le bit de signe a été inversé par un calcul. La règle est la suivante : le résultat est négatif si le bit de signe vaut 1 (donc résultat négatif) avant un débordement d'entier. Donc soit il n'y a pas eu de débordement et le bit de signe vaut 1, soit il y a eu un débordement et il est passé à 0. Le tout se détecte simplement en faisant un XOR entre le bit de débordement et le bit de signe.
[[File:Comparateur en complément à 2.png|centre|vignette|upright=2|Circuit de comparaison entière en complément à 2.]]
Le tout est résumé dans le tableau suivant :
{|class="wikitable"
|-
! Condition testée
! Calcul
|-
! A == B
| Z = 1
|-
! A != B
| Z = 0
|-
| colspan="3" | Opérandes non-signées
|-
! A < B
| C = 1
|-
! A >= B
| C = 0
|-
! A <= B
| C OU Z = 1
|-
! A > B
| C ET Z = 0
|-
| colspan="3" | Opérandes en complément à deux
|-
! A < B
| S XOR D = 1
|-
! A >= B
| S XOR D = 0
|-
! A <= B
| (S XOR D) OU Z = 1
|-
! A > B
| (S XOR D) ET Z = 0
|}
===Le comparateur complet===
Il est possible de raffiner ce circuit, en ajoutant un multiplexeur en sortie, afin de choisir une condition en sortie du circuit. L'idée est que le circuit possède une seule sortie, sur laquelle on a le résultat de la condition choisie. Pour configurer le multiplexeur, le circuit possède une entrée de commande sur laquelle on envoie un nombre, qui permet de choisir la condition à tester. Par exemple, pour vérifier si deux nombres sont égaux, on envoie sur l'entrée de commande le nombre qui correspond au test d'égalité.
[[File:Calcul d'une condition pour un branchement.png|centre|vignette|upright=1.5|Calcul d'une condition pour un branchement]]
Nous réutiliserons ce circuit bien plus tard dans ce cours, dans les chapitres sur les branchements. Mais ne vous inquiétez pas, dans ces chapitres, nous ferons des rappels sur les bits intermédiaires, la manière dont sont calculées les conditions et globalement sur tout ce qui a été dit dans cette section. Pas besoin de mémoriser par coeur les équations de la section précédente, tout ce qui compte est que vous ayez retenu le principe général.
===Les avantages du circuit comparateur-soustracteur===
Utiliser un soustracteur pour les comparaisons a de nombreux avantages. Les processeurs modernes supportent l'addition, la soustraction, les comparaisons et potentiellement d'autres opérations. Utiliser le soustracteur pour faire des comparaisons permet de se passer d'un circuit comparateur en plus du soustracteur. mutualiser les deux circuits entraine une économie de portes logiques assez conséquente. Un autre avantage est est plus rapide, car le soustracteur peut utiliser des techniques d'anticipation de retenue pour accélérer les calculs, au lieu de traiter les opérandes colonne par colonne. De plus, il peut comparer aussi bien des entiers codés en complément à deux que des entiers non-signés, là où les comparateurs précédents ne le permettaient pas.
Si on veut un circuit comparateur isolé, qui n'est pas fusionné avec un soustracteur, il est possible de prendre un soustracteur et de retirer les portes inutiles. En effet, on n'a pas besoin de calculer le résultat proprement dit. Vérifier si les deux opérandes sont égales peut se faire sans utiliser le résultat de la soustraction, cela demande juste d'utiliser un comparateur d'égalité entre les deux opérandes. Le calcul des trois autres bits intermédiaires se fait avec un soustracteur castré, qui calcule et propage les retenues, mais ne calcule les bits du résultat proprement dit (sauf le bit de signe). Concrètement, cela demande juste de retirer quelques portes XOR, une par soustracteur complet. Les portes XOR utilisées pour tester l'égalité peuvent être mutualisées avec le soustracteur castré.
==Les comparateurs pour opérandes entiers==
Dans ce qui suit, nous allons créer des comparateurs qui n'utilisent pas de soustraction. Ils sont composés de comparateurs 1-bit. Nous avons vu que ces comparateurs de 1 bit sont semblables à des demi-soustracteurs ou des demi-additionneurs complets, et cela se ressent sur la manière dont on combine leurs résultats. Il est possible de créer des comparateurs sériels, à propagation de retenue ou parallèle, sur le même modèle que les additionneurs/soustracteurs.
===Les comparateurs pour entiers non-signés===
Il existe un équivalent pour les comparaisons de l'additionneur à propagation de retenue. Il traite les bits les uns après les autres, avec un comparateur 1-bit par colonne. Pour vérifier quelle opérande est supérieure, on part du bit de poids fort. Tant que les bits des opérandes sont égaux, on passe à la colonne précédente. Le premier bit rencontré qui est différent entre les deux opérandes donne le résultat. Un inconvénient de cette méthode est que le résultat est lent à calculer, car on doit traiter les opérandes colonne par colonne, comme pour les additionneurs.
Pour donner un exemple, prenons le cas d'un comparateur qui vérifie si le premier opérande A est supérieur au second opérande B. La comparaison se fait bit par bit, en partant du bit de poids fort. Il y a alors deux cas : soit le bit An est supérieur au bit Bn, soit ils sont égaux. Dans le premier cas, la réponse est immédiate. Dans le second, le résultat est celui obtenu à la colonne précédente. Le circuit est donc composé en enchainant plusieurs briques de base, une par colonne, qui ressemblent à ceci :
[[File:Comparateur de supériorité 1 bit.png|centre|vignette|upright=2|Comparateur de supériorité 1 bit]]
Vérifier l'infériorité au lieu de la supériorité se fait avec un circuit très similaire. Il suffit de remplacer la porte logique qui vérifie "A > B" par une qui vérifie "A < B".
Le processeur HP Nanoprocessor, un des tout premiers microprocesseurs, vérifiait infériorité et supériorité. Il utilisait des comparateurs 1 bit qui fournissaient deux résultats : "A > B" et "A < B", chacun contenant de quoi combiner son résultat avec le résultat de la colonne précédente. Une différence est qu'il propageait ses résultats dans l'autre sens : il envoyait les deux résultats "A > B" et "A < B" à la colonne précédente. Nous ne détaillerons pas ici le circuit de combinaison, surtout qu'il suffit d'écrire sa table de vérité pour le concevoir. Un tel circuit n'a pas l'air très optimisé, mais il était économe en portes logiques.
[[File:Comparateur série.png|centre|vignette|upright=2|Comparateur série]]
Il est possible de créer un comparateur configurable, qui vérifie soit l'infériorité, soit la supériorité, suivant le bit envoyé sur une entrée de commande. Pour cela, il suffit de remplacer la porte logique mentionnée avant. L'idée est d'avoir une porte qui vérifie "A > B", une autre qui vérifie qui vérifie "A < B" et un multiplexeur pour choisir entre les deux. Les multiplexeurs des différentes colonnes sont tous commandés par le même signal de commande.
[[File:Comparateur configurable 1 bit.png|centre|vignette|upright=2.5|Comparateur configurable 1 bit]]
Les comparateurs précédents ont le même défaut que l'additionneur à propagation de retenue, à savoir que l'on doit traiter toutes les colonnes une par une, avant de donner un résultat. Le temps de calcul est donc proportionnel à la taille des opérandes. Mais il est possible de ruser, comme pour les additionneurs, en faisant des calculs en parallèle. L'idée est d'assembler plusieurs comparateurs simples pour traiter des nombres plus longs. Par exemple, on peut combiner 2 comparateurs 4 bits pour obtenir un comparateur 8 bits. Comme pour les additionneurs/soustracteurs, il y a deux possibilités : soit on enchaine les comparateurs en série, soit on les fait travailler en parallèle.
{|
|[[File:4 bit magnitude comparator extension.svg|centre|vignette|upright=1.5|Interface d'un comparateur parallèle avec "retenues".]]
|[[File:Parallel Magnitude Comparator.svg|centre|vignette|upright=1.5|Comparateur entier parallèle.]]
|}
Le comparateur sériel est construit sur le modèle de l'additionneur-soustracteur sériel. Mais un tel circuit est rarement utilisé, pour une raison simple : il est possible de modifier un additionneur-soustracteur sériel de manière à ce qu'il fasse des comparaisons. Le cout en circuits est ridicule, à peine une dizaine de portes logiques, pour un gain en fonctionnalités drastique. Personne n'a intérêt à utiliser un comparateur sériel quand il peut ajouter quelques portes pour supporter l'addition et la soustraction en plus. Aussi, tout ce qui suit étudiera des comparateurs dit parallèles, à savoir non-sériels.
[[File:Comparateur sériel.jpg|centre|vignette|upright=2|Comparateur sériel.]]
===Le comparateur signe-magnitude===
Les comparateurs précédents comparaient des opérandes non-signées, à savoir nulles ou positives, mais pas négatives. Et comparer deux opérandes signées n'est pas la même chose que comparer deux nombres non-signés, sauf éventuellement pour la comparaison d'égalité/différence. Et la comparaison ne s'effectue pas de la même manière selon que les nombres à comparer sont codés en signe-magnitude, en complément à deux, ou dans une autre représentation. Avec les représentations en complément à un ou à deux, le seul moyen pratique de faire une comparaison est d'utiliser une soustraction. Aussi, nous ne verrons que la comparaison en signe-magnitude.
Effectuer une comparaison entre deux nombres en signe-magnitude demande de comparer les valeurs absolues, mais il faut aussi tenir compte des bits de signe. Le comparateur en sgine-magnitude contient donc un circuit comparateur, qui compare les valeurs absolues. Le résultat de cette comparaison est alors combiné avec les bits de signe par un circuit combinatoire assez simple. Le circuit en question prend 4 bits : deux pour le résultat de la comparaison, et les deux bits de signe.
[[File:Comparateur en signe-magnitude.png|centre|vignette|upright=2|Comparateur en signe-magnitude]]
La comparaison des bits de signe prend le pas sur la comparaison des valeurs absolues. Par exemple, 2 est supérieur à -255, bien que ce ne soit pas le cas pour les valeurs absolues. Ce fait n'est que l'expression du fait qu'un nombre positif est systématiquement supérieur à un négatif, de même qu'un négatif est systématiquement inférieur à un positif. Ce n'est que quand les deux bits de signes sont égaux que la comparaison des valeurs absolue est à prendre en compte.
Pour des opérandes de même signe, la comparaison des valeurs absolues ne donne pas immédiatement le résultat. En effet, il y a un cas problématique : les opérandes négatives. Si je compare - 2 et -5, la comparaison des valeurs absolues me dit que 5 > 2. Pourtant, le résultat est que - 2 > -5, soit, l'inverse. Et le même phénomène a lieu quand on compare deux opérandes négatives. Intuitivement une solution est d'inverser les résultats des comparaisons de supériorité/infériorité. Mais il faut prendre en compte le cas où les deux opérandes sont nulles, ce qui biaise le résultat.
Voici la table de vérité de ce circuit, simplifiée :
{| class="wikitable" style="text-align:center"
|-
! Bit de signe de A
! Bit de signe de B
! colspan="2" | Résultats de la comparaison
! Sortie <
! Sortie >
|-
|0 (+) || 0 (+) || X || Y || X || Y
|-
|1 (-) || 0 (+) || X || Y || 0 || 1
|-
|0 (+) || 1 (-) || X || Y || 1 || 0
|-
|1 (-) || 1 (-) || X || Y || <math>\overline{X}</math> || <math>\overline{Y}</math>
|}
: Notons que la dernière ligne ne tient pas compte du cas où les deux valeurs absolues sont nulles.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits pour l'addition et la soustraction
| prevText=Les circuits pour l'addition et la soustraction
| next=Les unités arithmétiques et logiques entières (simples)
| nextText=Les unités arithmétiques et logiques entières (simples)
}}
</noinclude>
cq4irsdnzebelin4i04zw5373nzq1rn
Mathc initiation/a10
0
76338
775253
772069
2026-10-14T10:18:49Z
Xhungab
23827
775253
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a522#Analyse IV : Les équations différentielles|Sommaire]]
:
{{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c19|* La méthode de Euler]]}}
:
{{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c20|* La méthode de Heun]]}}
:
{{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c21|* La méthode de Runge Kutta]]}}
:
{{Partie{{{type|}}}|[[Mathc initiation/0084|* La méthode de la transformée de Laplace]]}}
{{AutoCat}}
mq1a2tjttm42m5ndz53v76xhmn9r4vi
Fonctionnement d'un ordinateur/Les optimisations du chargement des instructions
0
79799
775198
775007
2026-10-13T17:58:16Z
Mewtow
31375
/* La file d'instruction */
775198
wikitext
text/x-wiki
Les processeurs avec un pipeline sont découpés en deux sections : un ''front-end'' qui charge et décode les instructions, et un ou plusieurs ''back-end'' qui exécutent les instructions. L'exécution des micro-opérations est optimisée par des techniques que nous verrons dans la suite du cours : exécution dans le désordre, superscalarité, renommage de registre. Mais un point important est que le ''front-end'' a des optimisations dédiées. La prédiction de branchement est l'une de ces optimisation, mais elle n'est pas la seule. Ce chapitre aborde les optimisations autres que la prédiction de branchement. Nous allons voir le découplage des étages du ''front-end'', le pré-décodage et quelques améliorations liées indirectement à la prédiction de branchement.
==Le prédécodage d'instructions==
La présence d'un cache d'instruction permet l'implémentation de certaines optimisations, dont la plus connue est la technique dite du '''prédécodage'''. Avec elle, lorsque les instructions sont chargées dans le cache d'instruction, elles sont partiellement décodées, grâce à un circuit séparé de l'unité de décodage d'instruction. Le décodage de l'instruction proprement dit est plus court, car une partie du travail est faite en avance, on gagne quelques cycles.
[[File:Prédécodage des instructions dans le cache L1.png|centre|vignette|upright=2.5|Prédécodage des instructions dans le cache L1]]
Le prédécodage est particulièrement utile avec des instructions de taille variable : il permet de pré-déterminer où commencent/terminent les instructions dans une ligne de cache, indiquer leur taille, etc. Autre possibilité, le prédécodage peut indiquer s'il y a des branchements dans une ligne de cache et où ils se trouvent, ce qui est très utile pour la prédiction de branchement.
Pour chaque ligne de cache, le décodage partiel fournit des informations utiles au décodeur d'instruction. Les informations pré-décodées sont soit intégrée dans la ligne de cache, soit mémorisées dans une banque séparée. En clair : une partie de la capacité totale du cache d'instruction est utilisée pour les informations de pré-décodage. Le prédécodage est donc un compromis : un cache d'instruction de plus faible capacité, mais un décodage plus simple.
Le pré-décodage est surtout utile pour les instructions qui sont ré-exécutées souvent. Pour les instructions exécutées une seule fois, le gain en performance dépend de l'efficacité du préchargement et d'autres contraintes, mais ce qui est gagné lors du décodage est souvent partiellement perdu lors du prédécodage. Par contre, si une instruction est exécutée plusieurs fois, le pré-décodage est fait une seule fois, alors qu'on a un gain à chaque ré-exécution de l'instruction.
===Les sélecteurs de branchement intégrés au cache L1===
Le pré-décodage peut être utilisé afin de faciliter le travail de la prédiction de branchement. L'idée est d'incorporer une partie de la prédiction de branchement dans le cache L1 d'instruction. Une ligne de cache mémorise alors des informations de prédiction de branchement dans ses bits de contrôle. Les informations en question peuvent être des adresses de destination, ou simplement de quoi déterminer si le branchement est pris ou non. Il s'agit d'une démarche inverse à celle de la ''Fetch Target Queue'', qui découple l'unité de prédiction de branchement du cache, en insérant une mémoire FIFO entre les deux. Là, la démarche est au contraire de fusionner partiellement cache d'instruction et unité de prédiction de branchement.
Les premiers processeurs AMD utilisaient cette technique, au moins dans les grandes lignes. Une ligne de cache contient potentiellement plusieurs branchements, dont la position est identifiée par le prédécodage. Pour chaque octet, la ligne de cache associe un bit de contrôle qui indique si un branchement démarre à cet octet, si c'est le premier octet d'un branchement. Le prédécodage peut identifier entre un et plusieurs branchement par ligne de cache, il y a une limite. Le prédécodage n'identifie typiquement que les 3 à 5 premiers branchements, les suivants sont ignorés, faute de place dans les bits de contrôle.
Prenons par exemple une ligne de cache de 8 octets, dans laquelle on a 2 branchements de 2 octets chacun.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Branch 1 || bgcolor="#FFFF00" | Branch 1 || Instruction || bgcolor="#FFFF00" | Branch 2 || bgcolor="#FFFF00" | Branch 2 || Instruction || Instruction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 0 || 1 || 0 || 0 || 1 || 0 || 0 || 0
|}
Il est possible d'améliorer le tout en précisant quel est le type du branchement. Par exemple, on peut distinguer les branchements inconditionnel et conditionnels, ou encore les instruction de retour de fonction. L'intérêt n'est pas évident, mais c'est lié au fait que les branchements inconditionnels sont toujours pris, et que les retour de fonction ont une adresse de destination qui est prédite par une unité de branchement séparée, le ''return adress predictor'', pas par un BTB. Deux bits suffisent pour indiquer : si c'est un branchement conditionnel, inconditionnel, un retour de fonction, ou une instruction qui n'est pas un branchement.
{|class="wikitable" style="text-align:center;"
|-
! colspan="16 | Ligne de cache, en octets
|-
| Instruction || bgcolor="#FFFF00" | Saut inconditionnel || bgcolor="#FFFF00" | Saut inconditionnel || Instruction || bgcolor="#A00000" | Branch cond || bgcolor="#A00000" | Branch cond || Instruction || bgcolor="#F0F000" | Retour de fonction
|-
! colspan="16 | Bits d'identification des branchements.
|-
| 00 || 01 || 00 || 00 || 10 || 00 || 00 || 11
|}
L'idée est alors d'ajouter, pour chaque branchement détecté, un '''sélecteur de branchement''' qui indique si le branchement est pris ou non. En clair, des informations de prédiction de branchement sont ajoutés à chaque octet de position. Intuitivement, on se dit qu'il y a seulement un bit par branchement, qui indique si le branchement est pris ou non.
Les prédictions peuvent venir soit de l'unité de prédiction de branchement, soit provenir du prédécodage. Le prédécodage peut faire de la prédiction statique. Elle peut notamment détecter les branchements inconditionnels et les marquer comme pris. Elle peut aussi détecter les branchements conditionnels et le marquer comme non-pris par défaut. L'unité de prédiction de branchement met à jour les sélecteurs de branchements si besoin, pour les branchements conditionnels.
===L'incorporation du ''Branch Target Buffer'' dans le cache d'instruction===
Une première optimisation permet de se passer de ''Branch Target Buffer''. Pour rappel, celui-ci est un cache qui mémorise, pour chaque branchement, quelle est son adresse de destination. Il peut contenir d'autres informations de prédiction, mais laissons-les de côté pour le moment.
L'idée est de déplacer les adresse de destination des branchements dans le cache d'instruction, dans les lignes de cache. Si une ligne de cache contient un branchement, elle mémorise l'adresse de destination de ce branchement, en plus des bits de pré-décodage. En général, les processeurs ne supportent qu'une seule adresse de destination. Si il y a plusieurs branchements dans une ligne de cache, c'est l'adresse de destination du premier branchement pris dans cette ligne de cache qui est mémorisée. Par exemple, l'AMD K5 se passe de ''Branch Target Buffer'' grâce à cela.
Il faut cependant remarquer qu'à ce petit jeu, les instructions de retour de fonction sont à part. Leur adresse de destination est souvent donnée par une unité de branchement séparée, le ''return adress predictor'', séparée du ''Branch Target Buffer''. Leurs adresses de destination n'ont pas forcément besoin d'être mémorisées dans les lignes de cache.
La technique décrite ici est simple à comprendre. Cependant, les processeurs AMD anciens, d'architecture K6 à K10 n'utilisaient pas cette méthode, mais une variante plus complexe, capable de prédire jusqu'à deux adresses de destination par branchement. A partir de l'architecture K6, le prédécodage déterminait la position des branchements dans les lignes de cache, dans une limite de 4 branchements par ligne de cache. Pour chaque branchement, la ligne de cache mémorisait un sélecteur de branchement, codé sur 2 bits. La valeur des bits indiquait que le branchement n'est pas pris si elle vaut 00, que c'est une instruction de retour de fonction si elle vaut 01, qu'il faut brancher à l'adresse de destination X si elle vaut 10, qu'il faut brancher à l'adresse de destination X si elle vaut 11. Les adresses de destination sont quand à elles mémorisées dans un cache séparé, appelé le ''Branch Target Cache''. Le mécanisme pour adresser ce cache à partir du cache d'instruction n'est pas très détaillé dans la documentation d'AMD.
===Les avantages et inconvénients===
L'avantage de faire ainsi est que la prédiction de branchement est plus rapide. Lire une instruction depuis le cache renvoie non seulement l'instruction lue, mais aussi des informations de prédiction de branchement. L'unité de prédiction de branchement peut alors utiliser ces informations au cycle suivant pour savoir quelle est l'instruction suivante à charger.
Un défaut de cette approche est que si le branchement à prédire n'est pas dans le L1 d'instruction, aucune prédiction de branchement ne peut être faite et le préchargement ne peut pas fonctionner. C'est une limitation que n'ont pas les BTB découplées du cache L1 : elles peuvent prédire les adresses de destination et la direction d'un branchement, tant que l'entrée associée est dans le BTB. Et l'entrée peut être conservée, même si l'instruction en question a quitté le cache L1 et qu'elle est dans le L2, le L3 ou même en mémoire RAM. Les prédictions peuvent même servir à précharger les instructions utiles.
Sur l'Itanium et l'AMD Opteron, une optimisation assez intéressante permet de conserver les prédictions de branchement lorsque l'un branchement est évincé du cache L1 et se retrouve dans le cache L2. En théorie, les informations de prédiction, présentes dans la ligne de cache, sont perdues lorsque le branchement est évincé. Mais ces processeurs conservent ces prédictions dans un cache séparé, appelé le '''''L2 Branch Cache'''''.
==Le découplage du ''front-end''==
Le séquenceur d'un processeur contient une unité de chargement, un décodeur et un chemin de données. Avec un pipeline, le couplage de ces structures fait que si l'une d'entre elle prend plus d'un cycle pour faire son travail, les étages précédents et/ou suivants sont stoppés. Par exemple, lors d'un défaut dans le cache d'instruction, l'ensemble stoppe. De même, si jamais le décodeur doit décoder une instruction via le micro-code, cela prend plusieurs cycles : l'unité de chargement et le cache d'instruction sont inutilisés. Même chose si jamais une instruction multicycle s’exécute dans le pipeline : cela bloque toutes les étapes précédentes.
Mais il s'agit là d'un défaut inhérent aux pipelines basiques, qui relient chaque étage avec des registres. Avec un peu d'astuce, il est possible que certains étages prennent de l'avance même si l'étage suivant est bloqué. Par exemple, si le décodeur bloque le pipeline en utilisant son micro-code pendant 4-5 cycles, l'unité de chargement peut en théorie précharger à l'avance les instructions suivantes, et les mettre en attente. Ou encore, en cas de bulle de pipeline (''pipeline stall''), le décodeur peut décoder en avance des instructions et les mettre en attente tant que la bulle de pipeline est en cours.
Dans les deux cas, une unité prend de l'avance et met en attente ses résultats tant que l'étage suivant est occupé. La mise en attente est réalisée en remplaçant/complémentant les registres du pipeline avec des mémoires FIFOs. Elles remplacent ou complémentant les registres de pipeline, les deux sont possibles. Si elles remplacent ces registres, si aucune mise en attente n'est requise, elles fonctionnent comme un registre de pipeline.
===La file d'instruction===
[[File:File de micro-opérations.png|vignette|upright=1|File d'instructions]]
Les processeurs modernes intègrent une '''file d'instruction''', à savoir une mémoire FIFO placée entre le cache d'instruction et le décodeur d'instruction. Les instructions chargées par l'étape de chargement soient accumulées dans la file d'instructions et sont décodées quand l'unité de décodage est prête. La file d'attente permet de charger des instructions à l'avance, permettant ainsi de masquer certains accès au cache ou à la mémoire assez longs.
L'idée est que les instructions s'accumulent dans la file d'instruction si le processeur exécute les instructions moins vite qu'il ne les charge. C'est généralement signe qu'il effectue une instruction multicycle et/ou qu'il effectue un accès à la mémoire. À l'inverse, la file d'attente se vide quand le processeur exécute les instructions plus vite qu'il n'en charge. C'est généralement signe qu'un défaut de cache d'instruction est en cours.
La présence d'une file d'attente fait que la première situation est compensée lors de la seconde. Les temps d'attentes liées aux instructions multicycles permettent de remplir la file d'attente, qui est ensuite vidée en cas de défaut de cache. Le processeur exécute en permanence des instructions, sans interruption. Alors que sans file d'attente, les défauts de cache entraineront des temps d'attente où le processeur s’exécuterait rien.
La seule limite de cette optimisation est l'influence des branchements. Lorsqu'un branchement est décodé, ce tampon d’instructions est totalement vidé de son contenu. Ce n'est ni plus ni moins ce que faisait la ''prefetch input queue'' des anciens processeurs Intel, dont nous avions parlé dans le chapitre sur l'unité de chargement et le séquenceur.
===La file de micro-opérations===
[[File:File de micro-opération.png|vignette|upright=1|File d'instruction]]
L'optimisation précédente peut aussi s'appliquer entre le décodeur d'instruction et le chemin de données. Pour cela, la sortie du décodeur est reliée à une mémoire FIFO semblable à la file d'instruction. Elle mémorise les micro-opérations émises par le décodeur et les met en attente tant que le reste du pipeline n'est pas prêt. Nous l'appellerons la '''file de micro-opérations''', mais elle porte de nombreux noms et la terminologie des différents fabricants est assez confuse. Le schéma ci-contre indique que la file de micro-opérations est située en sortie de l’unité de décodage, avant l'unité d'émission et avant l'unité de renommage de registres (que nous aborderons dans quelques chapitres).
La file de micro-opérations permet aux décodeurs de faire leur travail même si le reste du pipeline n'est pas prêt. Par exemple, imaginons que le processeur ne peut pas émettre de nouvelle instruction, soit car toutes les ALUs sont occupées, soit car il y a un accès mémoire qui bloque le pipeline, peu importe. Sans file de micro-opérations, tout ce qui précède l'unité d'émission devrait être totalement bloqué tant que l'instruction ne peut pas être émise. Mais avec une file de micro-opérations, le pipeline peut charger et décoder des instructions, il a juste à accumuler les instructions décodées dans la file de micro-opérations. En clair, la file de micro-opérations met en attente les instructions décodées quand des bulles de pipeline sont émises.
Et à l'inverse, elle permet d'émettre des instructions quand les unités de décodage/chargement sont bloquées. Le cas classique est celui d'un défaut de cache dans le cache d'instruction. Pendant un défaut de cache, aucune instruction n'est chargée ni décodées durant quelques cycles. Sans file de micro-opérations, le processeur ne peut plus rien faire durant quelques cycles. Mais avec une file de micro-opérations, il se rattrape en émettant les instructions en attente dans la file de micro-opérations, s'il y en a. En clair, si l'unité d'émission a mis en attente des instructions, le processeur se rattrape au prochain défaut de cache d'instruction.
Une autre situation où le décodeur bloque est le cas où certaines instructions mettent du temps à être décodées. C'est notamment le cas de certaines instructions complexes, dont le décodage prend facilement 2 à 3 cycles d'horloge, voire plus. Le pire est le décodage des instructions microcodées, qui peut demander plusieurs cycles. Or, le pipeline demande qu'on décode une instruction par cycle pour éviter de bloquer le pipeline. Mais ce temps de décodage peut être masqué si des micro-opérations sont en attente dans la file, elles sont exécutées pendant le décodage long.
===Le ''Loop Stream Detector''===
Les boucles sont une opportunité d'optimisation très intéressante sur les CPU avec une file de micro-opérations. L'idée est que lors d'une boucle, des instructions sont chargées, décodées et exécutées plusieurs fois de suite. Mais à chaque répétition d'une instruction, le chargement et le décodage donnent toujours le même résultat, seule l'exécution n'est pas la même (les registres renommés sont aussi différents, mais nous verrons cela dans plusieurs chapitres). L'idée est simplement de mémoriser les N dernières instructions décodées et de les ré-exécuter si besoin. Ainsi, on évite de charger/décoder une même instruction machine plusieurs fois, mais de réutiliser les micro-opérations déjà décodées.
L'implémentation la plus simple utiliser la file de micro-opérations comme une sorte de pseudo-cache FIFO. La file de micro-opérations ne supprime pas les micro-opérations une fois qu'elles sont émises. Elle mémorise là où se trouve la dernière micro-opération émise, mais conserve celles qui ont déjà été émises. Un circuit annexe, appelé le '''''Loop Stream Detector''''' (LSD), détecte les boucles dans la file de micro-opérations et optimise leur exécution. Si une boucle adéquate est détectée par le ''Loop Stream Detector'', les micro-opérations de la boucle sont lues dans la file de micro-opération et sont injectées directement dans la suite du pipeline. De plus, les unités de chargement et de décodage sont désactivées pendant l’exécution de la boucle, ce qui réduit la consommation d'énergie du CPU.
L'optimisation accélère les petites boucles, qui tiennent toutes entières dans la file de micro-opérations, et sous condition qu'elles s'exécutent de la même manière à chaque exécution. De telles boucles exécutent une suite de N instructions, qui reste identique à chaque itération de la boucle. Le cas le plus simple est celui d'une boucle dans laquelle il n'y a pas de branchements. Pour les boucles normales, le processeur reprend une exécution normale quand on quitte la boucle ou quand son exécution change, par exemple quand un if...else, un return ou tout autre changement de flot de contrôle a lieu. Vu que toutes ces situations impliquent un branchement qui n'a pas été pris comme avant, le processeur n'utilise plus le ''Loop Stream Detector'' en cas de mauvaise prédiction de branchement.
Le LSD vise surtout à désactiver les décodeurs et l'unité de chargement lors de l'exécution d'une boucle. La désactivation peut être du ''clock gating'', voire du ''power gating'', être partielle ou totale. Dans le pire des cas, les unités de chargement peuvent continuer à charger des instructions en avance dans une file d'instruction, mais les décodeurs peuvent être désactivés. Dans le meilleur des cas, la totalité de ce qui précède la file de micro-opération est désactivé tant que la boucle s’exécute normalement. Y compris le cache de micro-opération.
[[File:Loop Stream Detector.png|centre|vignette|upright=2|Loop Stream Detector]]
Évidemment, la taille des boucles optimisées ainsi est limitée par la taille de la file de micro-opération, ce qui fait que l'optimisation ne fonctionne que pour des boucles de petite taille. Pour donner quelques chiffres, les processeurs ARM Cortex A15 géraient des boucles de maximum 32 micro-opérations. De plus, toute la file de micro-opération n'est pas gérée par le ''loop stream detector''. Par exemple, les processeurs avec une file de micro-opération de 64 micro-opération peuvent gérer des boucles de maximum 32 à 40 micro-opérations.
Mais les contraintes principales portent sur la détection des boucles. Le ''Loop Stream Detector'' ne peut pas détecter toutes les boucles qui existent, et certaines boucles ne sont pas détectées. Par exemple, le ''Loop Stream Detector' ne peut pas détecter les boucles si un appel de fonction a lieu dans la boucle. Il y a aussi des contraintes quant au nombre de branchements à l'intérieur de la boucle et le nombre d'accès mémoire.
Les CPU Intel modernes disposent d'un ''loop stream detector'', les CPU AMD en avaient sur les micro-architectures Zen 4 mais il a disparu sur la micro-architecture Zen 5. Quelques CPU ARM avaient aussi un ''loop stream detector'', notamment le Cortex A15. Il faut noter que le ''loop stream detector'' a été désactivé par des mises à jour de microcode sur quelques architectures, comme sur la micro-architecture Zen 4 d'AMD ou les CPU de micro-architecture Skylake et Kaby Lake d'Intel. Pour la micro-architecture Skylake, les raisons officielles pour cette désactivation sont un bug lié à l'interaction avec l'''hyperthreading''. Il est vraisemblable que des bugs ou des problèmes de sécurité aient amené à la désactivation sur les autres architectures.
===Le cache de micro-opérations===
Le '''cache de micro-opérations''' a le même but que le ''Loop Stream Detector'', à savoir optimiser l'exécution des boucles. La différence avec le ''Loop Stream Detector'' est qu'il y a un cache séparé de la file de micro-opérations, qui mémorise des micro-opérations décodées, dans le cas où elles soient réutilisées par la suite. La première itération d'une boucle accumule les instructions décodées dans le cache de micro-opérations, les itérations suivantes de la boucle lisent les micro-opérations adéquates dans le cache de micro-opération : on n'a pas à décoder l'instruction une nouvelle fois. Sur de nombreux processeurs, le cache de micro-opération est alimenté non pas par l'unité de décodage, mais par la file de micro-opérations. Ainsi, seules les micro-opérations émises sont copiées dans ce cache.
[[File:File de micro-opérations et cache de micro-ops.png|centre|vignette|upright=2|File de micro-opérations et cache de micro-ops]]
Les avantages sont les mêmes qu'avec un ''Loop Stream Detector'' : une consommation énergétique réduite, des performances légèrement améliorées. Le décodeur et l'unité de chargement sont inutiles en cas de succès dans le cache de micro-opération, ce qui fait qu'ils sont désactivés, éteints, ou du moins subissent un ''clock-gating'' temporaire. Ils ne consomment pas d'énergie, seul le cache de micro-opération utilise de l'électricité. L'avantage en termes de performance est plus faible, assez variable suivant la situation, mais aussi bien le cache de micro-opérations que le LSD ne font pas de mal.
Une différence avec le cache de micro-opération est qu'une boucle doit s’exécuter à l'identique avec un ''Loop Stream Detector'', pas avec un cache de micro-opérations. Prenons l'exemple d'une boucle contenant quelques instructions suivies par un IF...ELSE. Il arrive qu'une itération de la boucle exécute le IF, alors que d'autres exécutent le ELSE. Dans ce cas, le ''Loop Stream Detector'' ne sera pas activé, car la boucle ne s’exécute pas pareil d'une itération à l'autre. Par contre, un cache de macro/micro-opération mémorisera les micro-opérations du IF et du ELSE et les fournira selon les besoins. La raison est que le cache de micro-opérations a une politique de remplacement des lignes de cache plus complexe que le FIFO, typiquement une politique LRU ou LFU approximée. Le cache de micro-opération est donc plus efficace que le ''Loop Stream Detector'', pour un cout en transistor plus élevé.
Le cache de micro-opération est une voie de chargement parallèle au ''front-end'' proprement dit. L'accès au cache de micro-opération se fait lors de l'étape de chargement. Le cache de micro-opérations est adressé en envoyant le ''program counter'' sur son entrée d'adresse, en parallèle du cache d'instruction. En clair, il y a une voie qui regroupe cache d'instruction, file d'instruction et décodeur, et une seconde voie qui se résume au cache de micro-opération. Les deux voies sont accédées en parallèle. En cas de succès dans le cache de micro-opération, les micro-opérations adéquates sont lues directement depuis le cache de micro-opération.
Le cache de micro-opération associe, pour chaque instruction machine, une ou plusieurs micro-opérations. Avec l'implémentation la plus simple, une ligne de cache est associée à une instruction machine. Par exemple, sur les processeurs Intel de micro-architecture Skylake, chaque ligne de cache était associée à une instruction machine et pouvait contenir de 1 à 6 micro-opérations. Une instruction devait tenir toute entière dans une ligne de cache, ce qui fait que les instructions décodées en plus de 6 micro-opérations ne pouvaient pas rentrer dans ce cache.
Il existe deux méthodes différentes pour encoder les micro-opérations dans le cache de micro-opérations. La première est la plus intuitive : on mémorise les micro-opérations dans la ligne de cache, directement. Elle est utilisée sur les processeurs AMD, et sans doute sur les processeurs Intel récents. Mais les anciens processeurs Intel, comme ceux des architectures Sandy Bridge et Netburst, utilisent une autre méthode. Une ligne de cache mémorise non pas les micro-opération directement, mais un pointeur vers le ''control store'', qui indique à quelle adresse dans le micro-code se situe la micro-opération. La micro-opération est donc lue depuis le micro-code lors de son envoi au chemin de données, lors de son émission.
Il faut noter que pour des raisons de performance, le cache de micro-opérations est virtuellement tagué, ce qui fait qu'il est invalidé en cas de changement de programme. Sur l'architecture Sandy Bridge, il est carrément inclus dans le cache L1, les deux sont des caches inclusifs l'un avec l'autre. Les premières implémentations étaient très limitées. Les micro-opérations devaient être séquentielles dans le code, le cache était consulté seulement après un branchement et non à chaque instruction, pour limiter la consommation d'énergie an détriment des performances. Ces limitations ne sont pas présentes sur les architectures récentes.
Le cache de micro-opérations et le ''Loop Stream Detector'' font la même chose, mais certains processeurs implémentaient les deux. L'avantage est que le cache de micro-opération peut être désactivé si jamais le LSD détecte une boucle dans la file d'instruction, ce qui réduit encore plus la consommation énergétique. En pratique, l'impact sur la consommation énergétique est très difficile à mesurer, mais il rajoute de la complexité pour la conception du processeur.
[[File:File de micro-opérations et cache de micro-ops - Copie.png|centre|vignette|upright=2.5|File de micro-opérations et cache de micro-ops - Copie]]
===La ''Fetch Target Queue''===
Nous venons de voir qu'il est possible de découpler les étages de chargement, décodage et le chemin de données, en insérant des mémoires FIFOs dans le pipeline. Il en est de même avec l'unité de chargement elle-même. Elle est composée de deux circuits qu'on peut découpler : le cache d'instruction, l'unité de prédiction de branchement, et l'unité de calcul d'adresse. L'unité de calcul d'adresse regroupe : le ''program counter'', de quoi l'incrémenter, les MUX associés pour gérer les branchements.
Les processeurs modernes découplent l'unité de calcul d'adresse du cache d'instruction, en insérant une mémoire FIFO entre les deux. Les premiers articles scientifiques, qui ont proposé cette solution, l'ont appelée la '''''Fetch Target Queue''''', abréviée FTQ. Elle accumule les adresses à lire dans le cache d'instruction, peu importe que ces adresses viennent du ''program counter'' ou de l'unité de prédiction de branchement.
[[File:Fetch target queue.png|centre|vignette|upright=2.5|Fetch target queue]]
Elle se remplit quand le cache d'instruction est bloqué, soit à cause d'un défaut de cache, soit à cause d'un pipeline bloqué en amont de l'unité de chargement. Par exemple, si le cache d'instruction est bloqué par un défaut de cache, l'unité de prédiction de branchement peut accumuler des prédictions à l'avance dans la FTQ, qui sont ensuite consommées par le cache d'instruction une fois qu'il est redevenu disponible. De même, si l'unité de prédiction de branchement est bloquée par un évènement quelconque, le cache d'instruction peut consommer les prédictions faites à l'avance.
Une utilisation assez originale de la FTQ s'est vu sur les processeurs AMD d'architectures bulldozer. Sur cette architecture, les cœurs étaient regroupés par paquets de deux, et les deux cœurs partageaient certains circuits. Notamment, l'unité de prédiction de branchement était partagée entre les deux cœurs ! Pourtant, chaque cœur disposait de sa propre FTQ !
==Le ''Branch Folding'' et la file d'instruction==
Une file d'instruction basique précharge les instructions qui suivent la dernière instruction chargée. Mais une telle file d'instruction gère mal les branchements. Si un branchement est pris, les instructions de destination ne sont pas chargées. De plus, les instructions préchargées par erreurs doivent être supprimées pour éviter qu'elles soient décodées et exécutées, ce qui fait que la file d’instruction doit être invalidée.
[[File:Branchements et préchargement séquentiel.png|centre|vignette|upright=2|Branchements et préchargement séquentiel.]]
Heureusement, il existe des techniques qui visent à réduire l'impact des branchements sur la file d'instruction. L'idée est d'exécuter les branchements à l'avance, avant même leur décodage ! L'optimisation en question est appelée le '''''Branch Folding''''', et a été utilisé sur les processeurs PowerPC, pour ne citer qu'eux.
Le ''branch folding'' a une ressemblance très forte avec le préchargement appliqué aux instructions. Il faut dire qu'une file d'instruction a pour rôle de charger des instructions ''à l'avance''. La différence est la cible : dans la file d'instruction pour le ''branch folding'', dans le cache pour le préchargement d'instruction. Pour faire la distinction avec les autres formes de préchargement, nous parlerons de '''préchargement interne'''.
Les algorithmes utilisés pour le préchargement interne sont cependant les mêmes que pour le préchargement d'instruction. Aussi, nous allons les voir en même temps. Le préchargement séquentiel a pour équivalent... la file d'instruction elle-même. Du moins, dans son fonctionnement normal. Voyons maintenant les autres algorithmes.
===Le ''target line prefetching''===
Une version améliorée prend en charge les branchements d'une manière un peu plus naturelle. Elle agit comme le préchargement séquentiel, sauf quand elle rencontre un branchement inconditionnel. Dans ce cas, elle précharge les instructions à partir de l'adresse de destination du branchement. Il s'agit du '''''target line prefetching'''''.
[[File:Target line prefetching.png|centre|vignette|upright=2|Target line prefetching.]]
La technique demande de détecter les branchements inconditionnels. Pour cela, une unité spécialisée scanne la file d'instruction, pour y trouver les branchements. L'unité en question s'appelle l''''unité de ''branch folding'''''. Si un branchement inconditionnel est détecté dedans, elle utilise le ''target line prefetching''. L'adresse de destination est extraite du branchement inconditionnel, et le préchargement continue à partir de cette adresse. Pour cela, le ''program counter'' est altéré en avance. De plus, le branchement est remplacé par l'instruction de destination du branchement. Les instructions qui "suivent" le branchement sont annulées avant, histoire d'éliminer les instructions chargées à tord.
[[File:Branch folding.png|centre|vignette|upright=2|Branch folding.]]
Maintenant, qu'en est-il des branchements conditionnels ? La réponse de si la prédiction de branchement est utilisée ou pas. Sans prédiction de branchement, les branchements conditionnels sont considérés comme non-pris. Avec prédiction de branchement, tout dépend de si le branchement est prédit comme pris ou non. Si le branchement est considéré comme pris, il est traité comme un branchement inconditionnel : il est remplacé par l'instruction de destination et le ''program counter'' est altéré. Si le branchement est non-pris, il suffit de ne rien faire et de continuer le préchargement séquentiel.
Notez qu'il faut que l'adresse de destination soit connue à l'avance, ou du moins puisse être calculée facilement (branchements relatifs). L'unité de branchement peut contenir un additionneur afin de calculer l'adresse de destination d'un branchement relatif.
===Le préchargement du mauvais chemin===
Le ''target line prefetching'' peut être associé avec le préchargement séquentiel, pour gérer les branchements conditionnels au miuex. L'idée est de précharger à la fois les instructions après le branchement et celles à sa destination. Comme ça, peu importe que le branchement soit pris ou non, les instructions adéquates auront été préchargées. On appelle cette technique le '''préchargement du mauvais chemin''' (''wrong path prefetching''). La technique était implémentée sur le mainframe IBM 370/165 et quelques modèles IBM similaires. Les processeurs PowerPC utilisaient aussi cette technique.
[[File:Préchargement du mauvais chemin.png|centre|vignette|upright=2|Préchargement du mauvais chemin.]]
Le préchargement du mauvais chemin demande d'utiliser deux files d'instructions séparées. L'une dans laquelle on précharge de manière séquentielle, l'autre utilisant la ''target line prefetching''. Le choix de la bonne file se fait avec un multiplexeur. Une fois le branchement conditionnel exécuté, on sait s'il est pris ou non, le contenu de la file adéquate est conservé, l'autre est intégralement invalidée.
[[File:Branch folding avec prédiction du mauvais chemin.png|centre|vignette|upright=2|''Branch folding'' avec prédiction du mauvais chemin.]]
Le préchargement du mauvais chemin réduit un peu la pénalité en cas de mauvaise prédiction de branchement, en économisant quelques cycles lors de l'accès au cache d'instruction, guère plus. Le gain est maximal lorsque charger l'instruction de destination déclenche un défaut de cache. Le préchargement déclenche alors le défaut de cache en avance de quelques cycles, qui peuvent être traité avant même que la mauvaise prédiction de branchement soit détectée.
Elle a le défaut de bouffer beaucoup de débit binaire, au niveau du cache d'instruction. Elle fonctionne mieux si l'on peut précharger deux instructions par cycle, une par chemin. Et cela demande un cache d'instruction double port, dont le cout en circuit n'est pas négligeable. Et le gain en performance est assez faible.
Les premiers processeurs PowerPC utilisaient cette optimisation. Ils avaient une file d'instruction de 8 instructions, et l'unité de branchement scannait les 4 premières instructions. L'unité de ''branch folding'' contenait les registres nécessaires pour gérer les branchements. Elle contenait précisément le ''link register'' pour des appels de procédures, le ''Count Target Register'' et le ''Count Register'' utilisés pour les boucles. Elle contient aussi un additionneur pour calculer les adresses des branchements relatifs.
L'unité de ''branch folding'' identifiait les branchements, sans décoder les instructions en attente. A la place, les instructions étaient pré-décodées dans le cache d'instruction, qui identifiait la position des branchement. Les informations de prédécodage étaient utilisées pour remplir la file d'instruction. Chaque instruction dans la file d'instruction est associée à un bit qui indique si c'est un branchement ou non. L'unité de branchement anticipé peut alors détecter les branchements avec un simple encodeur à priorité.
[[File:Branch folding des CPU PowerPC.png|centre|vignette|upright=2|Branch folding des CPU PowerPC - les bits de prédécodage sont en couleur. Les branchement sont en rouge, les autres instructions en jaune.]]
===Le préchargement du mauvais chemin avec plusieurs branchements consécutifs===
Un défaut du préchargement du mauvais chemin est qu'elle gère mal le cas où deux branchements sont assez proches dans le code machine. Un branchement scinde le flux d'instructions : un où le branchement est pris, un autre où il ne l'est pas. Chacun de ces flux peut lui-même contenir un branchement, et se scinder lui aussi. Et ainsi de suite. Et le processeur doit gérer cette situation en termes de préchargement.
[[File:Exécution stricte 04.png|centre|vignette|upright=2|Exécution stricte]]
Plusieurs solutions existent. La première est de ne pas utiliser le ''target line prefetching'' au-delà de deux branchements. La méthode la plus simple stoppe le chargement du flux en attendant que le premier branchement soit terminé. Une solution un peu moins intuitive utilise le préchargement du mauvais chemin pour le premier branchement, mais bascule sur le préchargement séquentiel pour les branchements suivants. Cette solution est celle qui est systématiquement implémentée, car elle est simple à implémenter. Une dernière possibilité est d'utiliser la prédiction de branchement au-delà du premier branchement, afin de poursuivre le chargement de manière spéculative.
[[File:Exécution stricte 01.png|centre|vignette|upright=2|Exécution stricte, seconde.]]
Une autre possibilité consiste à utiliser le préchargement du mauvais chemin pour le second branchement, ainsi que ceux des flux extérieurs. Cette dernière est impraticable car elle demande des caches avec un grand nombre de ports et la présence de plusieurs files d'instructions, qui sont utilisées assez rarement. Et quitte à gaspiller autant de transistors, autant les utiliser décoder/exécuter directement les instructions, au lieu de les accumuler dans des files d'instruction. C'est ce qu'on appelle l''''exécution stricte''' (''eager execution'').
Quelques papiers de recherche ont étudié l'idée, mais ses défauts font qu'elle n'a jamais été utilisée dans un processeur en dehors de prototypes de recherche. Le gros problème de l'exécution stricte est qu'on est limité par le nombre d'unités de calculs, de registres, etc. Autant ce serait une technique idéale sur des processeurs avec un nombre illimité de registres ou d'unités de calcul, autant ce n'est pas le cas dans le monde réel. Au bout d'un certain nombre d’embranchements, le processeur finit par ne plus pouvoir poursuivre l’exécution, par manque de ressources matérielles et doit soit stopper, soit recourir à la prédiction de branchement.
==Le préchargement d'instructions==
Dans le chapitre sur le préchargement, nous avons volontairement mis de côté les techniques de ''préchargement d'instruction'', car ce n'était pas le moment d'en parler. Mais le moment est venu d'aborder le préchargement pour les instructions, d’où cette section. La section précédente parlait d'une forme de préchargement qui n'en est pas vraiment, car elle n'implique pas de mémoire cache. Par contre, le préchargement que nous allons voir le fait. Le '''préchargement d'instruction''' charge des instructions dans le cache L1 d’instruction. Les instructions proviennent du cache L2, qui mémorise aussi bien données qu'instructions. Aussi, nous parlerons de '''préchargement L2-L1i''', sous entendu : du cache L2 vers le cache L1 d'instruction.
Pour comprendre comment s'effectue le préchargement L2-L1i, rappelons que l'unité de chargement émet des adresses consommées par le cache d'instruction. Le couplage de ces structures fait qu'au moindre défaut de cache d'instruction, l'ensemble stoppe. Idem en cas de blocage du pipeline au-delà du cache, par exemple en cas de bulle de pipeline prolongée. Et c'est lors de ces blocages que le préchargement d'instruction se met en place. Lors de ces blocages, une unité de préchargement envoie des lectures au cache L2, pour précharger les instructions adéquates. Elle prédit quelles sont les instructions futures soit en se basant sur la prédiction de branchements, soit en regardant le contenu de la FTQ, soit autrement.
[[File:Préchargement d'instructions.png|centre|vignette|upright=2.5|Préchargement d'instructions]]
L'unité de préchargement peut utiliser n'importe quel algorithme de préchargement : du préchargement séquentiel, du ''target-line prefetching'', ou du préchargement du mauvais chemin.
* Avec le préchargement séquentiel, le préchargement charge la ligne de cache suivante à la dernière ligne de cache accédée.
* Le ''target-line prefetching'' s'enclenche en présence de branchements : l'unité de prédiction de branchement donne l'adresse de destination d'un branchement, et c'est la ligne de cache de la destination qui est chargée.
* Avec le préchargement du mauvais chemin, deux lignes de cache sont préchargées : celle avec ''target-line prefetching'', celle avec préchargement séquentiel. Elle n'a de sens que pour les branchements conditionnels.
Le préchargement séquentiel est facile à implémenter, car il n'a pas à utiliser la prédiction de branchement. Il a juste à analyser les derniers accès mémoire du cache d'instruction. Il n'a pas de différence avec un ''prefetcher'' séquentiel, tel qu'on l'a vu dans le chapitre sur le préchargement. Les deux autres formes de préchargement sont elles bien plus compliquées.
===L'anticipation du ''program counter''===
Avec la solution la plus simple, on a une unité de chargement qui s'occupe des accès au cache d'instruction, et une unité de préchargement qui prend de l'avance sur l'unité de chargement, et communique avec le cache L2. La technique la plus basique se base sur un ''Lookahead program counter'', un second ''program counter'' qui ne fonctionne que lors d'un défaut de cache d'instruction. Il est initialisé avec le ''program counter'' lors d'un défaut de cache, puis il est incrémenté à chaque cycle et les branchements sont prédits, ce qui fait qu'il est mis à jour comme si l’exécution du programme se poursuivait, alors que le reste du processeur est mis en attente.
La technique initiale utilisait ce second ''program counter'' pour accéder à une table de prédiction, qui associe à chaque valeur du ''program counter'', l'adresse des données chargées par l'instruction associée. Les adresses fournies à chaque cycle par cette table sont alors envoyées aux unités de préchargement pour qu'elles fassent leur travail. La technique permettait donc de précharger des données en cas de défaut de cache, mais pas d'instructions. Il ne s'agissait pas d'une technique de préchargement des instructions, mais de préchargement de données.
La technique a ensuite été adaptée pour le chargement des instructions par Chen, Lee et Mudge. Leur idée utilisait deux unités de prédiction de branchements : une couplée à l'unité de chargement, l'autre pour le préchargement. La première utilisait le ''program counter'' normal, l'autre se déclenchait en cas de défaut de cache et utilisait un ''lookahead program counter''. Les adresses générées par le ''lookahead program counter'' étaient envoyée au cache d'instruction, sur un port de lecture séparé. La ligne de cache lue était alors prédécodée pour détecter les branchements, qui étaient prédits, et rebelote. Il est possible d'adapter la méthode pour que les adresses soient accumulées dans une mémoire FIFO, et étaient consommée par le cache d'instruction L2 pour le préchargement si la ligne de cache associée n'était pas dans le cache d’instruction.
Les techniques modernes n'utilisent plus de seconde unité de prédiction de branchement, mais conservent un ''lookahead program counter''. Par contre, le BTB dispose de plusieurs ports : un pour la prédiction de branchement normale, l'autre pour le préchargement. L'unité de préchargement et l'unité de chargement accèdent toutes deux au BTB quand elles ont besoin de faire leurs prédictions, en parallèle. Typiquement, le BTB est accédé à chaque cycle pour la prédiction de branchement, à un rythme plus faible pour le préchargement.
===Le ''Fetch Directed Instruction Prefetching''===
Les processeurs modernes semblent utiliser un algorithme connu sous le nom de '''''Fetch Directed Instruction Prefetching'''''. Il utilise les adresses contenues dans la FTQ pour précharger les instructions adéquates du cache L2 vers le cache L1 d'instruction (L1i). L'unité de préchargement est placée en aval de la FTQ, elle lit son contenu, détecte quelles adresses correspondent à des lignes de cache à précharger, et envoie celles-ci au cache L2. Le préchargement du L2 vers le L1i a lieu quand le cache L2 est inutilisé, ou du moins quand il peut accepter une nouvelle lecture (dans le cas d'un cache multiport et/ou pipeliné).
[[File:Fetch directed instruction prefetching.png|centre|vignette|upright=2.5|Fetch directed instruction prefetching]]
On peut améliorer légèrement le design précédent sur plusieurs points. Pour éviter de polluer le cache L1 avec des lignes de caches préchargées à tort, il est possible d'ajouter un équivalent des ''stream buffer'' vus dans le chapitre sur le préchargement. Il s'agit d'une autre mémoire FIFO qui mémorise les lignes de cache préchargées. Les lignes de cache préchargées ne sont pas placées dans le cache L1i, mais dans cette file d'attente. Lors d'un accès au L1i, la file d'attente est consultée en parallèle. Si l'instruction voulue est dans la file d'attente, elle est lue depuis la file, et la ligne de cache associée est copiée dans le cache L1i. Mais c'est là une possibilité facultative.
Un autre point est que l'unité de préchargement doit attendre que le cache L2 puisse accepter une nouvelle lecture pour lancer le préchargement d'une autre ligne de cache. Pour corriger cela, on ajoute une file d'attente entre le cache L2 et l'unité de préchargement, qui est évidemment une mémoire FIFO. Son utilité dépend des temps de lectures du cache L2, ainsi que de la taille de la FTQ. Elle n'est pas toujours nécessaire, certains processeurs ont un cache L2 assez lent pour qu'on ne puisse précharger qu'une seule ligne de cache avant que la FTQ soit complétement vide.
Ces deux optimisations sont facultatives, mais elles étaient présentes dans l'article originel qui a proposé la technique.
L'unité de préchargement doit détecter quelles sont les adresses de la FTQ qui ne sont pas déjà chargées dans le L1i. En effet, il est inutile de précharger une ligne de cache si celle-ci est déjà dans le cache L1i. L'unité de préchargement doit donc filtrer au mieux les adresses de la FTQ en deux classes : celles qui correspondent à une ligne de cache déjà dans le L1i, celles qui doivent être préchargées.
Pour cela, l'unité de préchargement utilise la technique dit du '''''Cache Probe Filtering'''''. L'idée part du principe que le cache d'instruction L1 est multiport. Les ports du cache d'instruction ne sont pas toujours utilisés en même temps et il arrive qu'il y ait un port de lecture de libre. Le CPF utilise alors ce port inutilisé pour vérifier si la prochaine ligne de cache à précharger est dans le cache ou non. Si c'est le cas, on aura un succès de cache : la ligne de cache est oubliée, elle ne sera pas préchargée. Si ce n'est pas le cas on aura un défaut de cache : la ligne sera préchargée.
Notez que l'on a pas besoin de lire la ligne en question, juste de vérifier les tags du cache. Dans ce cas, on peut ajouter des signaux de commande spécifiques pour le CPF, qui font une demi-lecture, qui ne vérifie que les tags, mais ne lit pas la donnée. On peut par exemple ajouter un port spécifique pour le CPF, purement en lecture et qui ne permet que de vérifier les tags. Ce port en plus a un cout en circuits plus faible qu'un port de lecture normal, mais ce n'est pas gratuit du tout.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=La prédiction de branchement
| prevText=La prédiction de branchement
| next=L'émission des instructions
| nextText=L'émission des instructions
}}
</noinclude>
{{AutoCat}}
etw48k6eqiq34vc9ic4cf5e64p583ar
Fonctionnement d'un ordinateur/Dépendances structurelles
0
81051
775163
730373
2026-10-13T14:38:44Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions]] vers [[Fonctionnement d'un ordinateur/L'émission des instructions]]
775163
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/L'émission des instructions]]
d4p8vhy6k0yw8y4houtklirkkdyx634
Fonctionnement d'un ordinateur/Exécution dans le désordre
0
81052
775164
747338
2026-10-13T14:38:54Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775164
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Fonctionnement d'un ordinateur/Les défauts du pipeline et leur correction : apercu
0
81053
775166
747340
2026-10-13T14:39:14Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775166
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Mathc initiation/a594
0
81090
775132
775131
2026-10-13T12:07:17Z
Xhungab
23827
775132
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
==Les series de Fourier :==
.
* <math>\mathbf{S(t) =a_0 + \sum_{n = 1}^{\infty} \left ( a_n \cdot \cos \left ( nt\frac{2\pi}T\right ) + b_n \cdot \sin \left ( nt\frac{2\pi}T\right )\right )}</math>
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
.
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
* [[Mathc initiation/0080|Un exemple avec atan2();]]
{{AutoCat}}
epdlyf95ioki676iqzl3r0g2dbm1umw
775133
775132
2026-10-13T12:15:22Z
Xhungab
23827
775133
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
==Les series de Fourier :==
.
* <math>\mathbf{S(t) =a_0 + \sum_{n = 1}^{\infty} \left ( a_n \cdot \cos \left ( nt\frac{2\pi}T\right ) + b_n \cdot \sin \left ( nt\frac{2\pi}T\right )\right )}</math>
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
==Quelques concepts :==
* [[Mathc initiation/0081|Étudions le rôle des harmoniques]]
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
* [[Mathc initiation/0080|Un exemple avec atan2();]]
{{AutoCat}}
jppsy11yuzr9sub50ju77bix3u8reyo
775135
775133
2026-10-13T12:36:23Z
Xhungab
23827
775135
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
==Les series de Fourier :==
.
* <math>\mathbf{S(t) =a_0 + \sum_{n = 1}^{\infty} \left ( a_n \cdot \cos \left ( nt\frac{2\pi}T\right ) + b_n \cdot \sin \left ( nt\frac{2\pi}T\right )\right )}</math>
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
==Quelques concepts :==
* [[Mathc initiation/0081|Étudions le rôle des harmoniques]]
* [[Mathc initiation/0082|Étudions la notion de fréquences]]
----
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
* [[Mathc initiation/0080|Un exemple avec atan2();]]
{{AutoCat}}
cl1g5kvmnvm3uhq66y82jtozeel7w98
775136
775135
2026-10-13T12:37:58Z
Xhungab
23827
775136
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
==Les series de Fourier :==
* <math>\mathbf{S(t) =a_0 + \sum_{n = 1}^{\infty} \left ( a_n \cdot \cos \left ( nt\frac{2\pi}T\right ) + b_n \cdot \sin \left ( nt\frac{2\pi}T\right )\right )}</math>
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
==Quelques concepts :==
* [[Mathc initiation/0081|Étudions le rôle des harmoniques]]
* [[Mathc initiation/0082|Étudions la notion de fréquences]]
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
* [[Mathc initiation/0080|Un exemple avec atan2();]]
{{AutoCat}}
2tbhs40fte22rxliza5kbrzn44omzwg
775140
775136
2026-10-13T13:31:26Z
Xhungab
23827
775140
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]]
[[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]]
'''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].'''
En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia]
.
==Les series de Fourier :==
* <math>\mathbf{S(t) =a_0 + \sum_{n = 1}^{\infty} \left ( a_n \cdot \cos \left ( nt\frac{2\pi}T\right ) + b_n \cdot \sin \left ( nt\frac{2\pi}T\right )\right )}</math>
'''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]'''
* <math> \mathbf{a_0=\frac1T\int_Tf(t)\,\mathrm{d}t}</math>
* <math>\mathbf{b_0=0}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
* '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math>
==Quelques concepts :==
* [[Mathc initiation/0081|Étudions le rôle des harmoniques]]
* [[Mathc initiation/0082|Étudions la notion de fréquences]]
* [[Mathc initiation/0083|Étudions le rôle de la phase]]
==Se familiariser avec les series de Fourier :==
Quelques exemples :
* [[Mathc initiation/a593|La fonction : f(t) = t ...................... avec t = [0..PI]]]
* [[Mathc initiation/007y|La fonction : f(t) = t ...................... avec t = [-PI..PI]]]
* [[Mathc initiation/007n|La fonction : f(t) = t^2 .................. avec t = [-PI..PI]]]
* [[Mathc initiation/a602|La fonction : f(t) = (t(t+1)) ........... avec t = [-1..1]]]
==Calculons les amplitudes An et les phases φn==
* [[Mathc initiation/a9b|Présentation]]
* [[Mathc initiation/0080|Un exemple avec atan2();]]
{{AutoCat}}
8a8gf7zlz6crvt08u3x2tvpnbr4gq1y
Mathc initiation/a601
0
81095
775208
737545
2026-10-13T19:30:13Z
Xhungab
23827
775208
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0a0.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double a = 0.;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a, b);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a0 = 1/T int( (%s) dx = %.6f\n"
" (a \n\n\n\n", feq, a0);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( (t) dx = 1.570796
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
051ddkdk0h3n10vaj63869829lspqio
Mathc initiation/a596
0
81096
775209
774476
2026-10-13T19:30:39Z
Xhungab
23827
775209
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0an.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = a+T;
double n = 1.; /* You can change the value of n */
double an = 2./T * fourier_an(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_%.0f = 2/T int( (%s) cos(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, an);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_1 = 2/T int( (t) cos(1 omega t) dt = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
8jmsywmd9lyzkjdwh9r0kt8soylt7uf
Mathc initiation/a597
0
81097
775210
774475
2026-10-13T19:31:09Z
Xhungab
23827
775210
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0bn.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = a+T;
double n = 1.; /* You can change the value of n */
double bn = 2./T * fourier_bn(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" b_%.0f = 2/T int( (%s) sin(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n", n, feq, n, bn);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
b_1 = 2/T int( (t) sin(1 omega t) dt = -1.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
9svjvc4eise1aa4e2fp23ej32ey6mix
Mathc initiation/a598
0
81098
775175
774519
2026-10-13T14:49:30Z
Xhungab
23827
775175
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = PI;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 1.570796
+1.570796327\
+0.000000000*cos(2.000000000* 1*x) -1.000000005*sin(2.000000000* 1*x)\
+0.000000000*cos(2.000000000* 2*x) -0.500000043*sin(2.000000000* 2*x)\
+0.000000000*cos(2.000000000* 3*x) -0.333333480*sin(2.000000000* 3*x)\
+0.000000000*cos(2.000000000* 4*x) -0.250000347*sin(2.000000000* 4*x)\
+0.000000000*cos(2.000000000* 5*x) -0.200000678*sin(2.000000000* 5*x)\
+0.000000000*cos(2.000000000* 6*x) -0.166667841*sin(2.000000000* 6*x)\
+0.000000000*cos(2.000000000* 7*x) -0.142859010*sin(2.000000000* 7*x)\
+0.000000000*cos(2.000000000* 8*x) -0.125002792*sin(2.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
szyqmbg0m686yx9xa5iz96f5var41bx
775211
775175
2026-10-13T19:31:41Z
Xhungab
23827
775211
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 1.570796
+1.570796327\
+0.000000000*cos(2.000000000* 1*x) -1.000000005*sin(2.000000000* 1*x)\
+0.000000000*cos(2.000000000* 2*x) -0.500000043*sin(2.000000000* 2*x)\
+0.000000000*cos(2.000000000* 3*x) -0.333333480*sin(2.000000000* 3*x)\
+0.000000000*cos(2.000000000* 4*x) -0.250000347*sin(2.000000000* 4*x)\
+0.000000000*cos(2.000000000* 5*x) -0.200000678*sin(2.000000000* 5*x)\
+0.000000000*cos(2.000000000* 6*x) -0.166667841*sin(2.000000000* 6*x)\
+0.000000000*cos(2.000000000* 7*x) -0.142859010*sin(2.000000000* 7*x)\
+0.000000000*cos(2.000000000* 8*x) -0.125002792*sin(2.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
ky88btxlh6zvowypab16j4lkwe5gpwg
Mathc initiation/a604
0
81101
775230
774444
2026-10-13T19:41:42Z
Xhungab
23827
775230
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1a0.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double a = -1;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a, b);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_0 = 1/T int( (%s) dx = %.6f\n"
" (a \n\n\n\n", feq, a0);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_0 = 1/T int( ((x(x+1))) dx = 0.333333
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
gex7yi67s147jme3cb15y7ypke723c8
Mathc initiation/a605
0
81102
775231
774473
2026-10-13T19:42:09Z
Xhungab
23827
775231
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1an.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double omega = (2.*PI/T);
double a = -1;
double b = a+T;
double n = 1.; /* You can change the value of n */
double an = 2./T * fourier_an(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_%.0f = 2/T int( (%s) cos(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, an);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_1 = 2/T int( ((x(x+1))) cos(1 omega t) dt = -0.405285
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
botpwwscqygpbmb2b5cjc1alv4bku7t
Mathc initiation/a606
0
81103
775232
774474
2026-10-13T19:42:41Z
Xhungab
23827
775232
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1bn.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double omega = (2.*PI/T);
double a = -1;
double b = a+T;
double n = 3.; /* You can change the value of n */
double bn = 2./T * fourier_bn(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" b_%.0f = 2/T int( (%s) sin(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, bn);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
b_3 = 2/T int( ((x(x+1))) sin(3 omega t) dt = 0.212207
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
fa3ppd534ad38bf5vrvngwr89jivokv
Mathc initiation/a607
0
81104
775233
774514
2026-10-13T19:43:00Z
Xhungab
23827
775233
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double omega = (2.*PI/T);
double a = -1;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 0.333333
+0.333333333\
-0.405284728*cos(3.141592654* 1*x) +0.636619776*sin(3.141592654* 1*x)\
+0.101321157*cos(3.141592654* 2*x) -0.318309914*sin(3.141592654* 2*x)\
-0.045031578*cos(3.141592654* 3*x) +0.212206684*sin(3.141592654* 3*x)\
+0.025330190*cos(3.141592654* 4*x) -0.159155164*sin(3.141592654* 4*x)\
-0.016211224*cos(3.141592654* 5*x) +0.127324386*sin(3.141592654* 5*x)\
+0.011257671*cos(3.141592654* 6*x) -0.106104043*sin(3.141592654* 6*x)\
-0.008270792*cos(3.141592654* 7*x) +0.090946870*sin(3.141592654* 7*x)\
+0.006332148*cos(3.141592654* 8*x) -0.079579249*sin(3.141592654* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
sciidmtss9wfu0o9yusralxla7dhuer
Mathc initiation/a615
0
81129
775177
774520
2026-10-13T14:52:00Z
Xhungab
23827
775177
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = PI;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="c">
+1.570796327\
+0.000000000*cos(2.000000000* 1*x) -1.000000005*sin(2.000000000* 1*x)\
+0.000000000*cos(2.000000000* 2*x) -0.500000043*sin(2.000000000* 2*x)\
+0.000000000*cos(2.000000000* 3*x) -0.333333480*sin(2.000000000* 3*x)\
+0.000000000*cos(2.000000000* 4*x) -0.250000347*sin(2.000000000* 4*x)\
+0.000000000*cos(2.000000000* 5*x) -0.200000678*sin(2.000000000* 5*x)\
+0.000000000*cos(2.000000000* 6*x) -0.166667841*sin(2.000000000* 6*x)\
+0.000000000*cos(2.000000000* 7*x) -0.142859010*sin(2.000000000* 7*x)\
+0.000000000*cos(2.000000000* 8*x) -0.125002792*sin(2.000000000* 8*x)\
+0.000000000*cos(2.000000000* 9*x) -0.111115094*sin(2.000000000* 9*x)\
...
+0.000000000*cos(2.000000000* 45*x) -0.022863618*sin(2.000000000* 45*x)\
+0.000000000*cos(2.000000000* 46*x) -0.022433331*sin(2.000000000* 46*x)\
+0.000000000*cos(2.000000000* 47*x) -0.022027209*sin(2.000000000* 47*x)\
+0.000000000*cos(2.000000000* 48*x) -0.021644202*sin(2.000000000* 48*x)\
+0.000000000*cos(2.000000000* 49*x) -0.021283386*sin(2.000000000* 49*x)\
+0.000000000*cos(2.000000000* 50*x) -0.020943951*sin(2.000000000* 50*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
s8q78vlrk587qutk3lvtv3dv1gf38sd
775212
775177
2026-10-13T19:32:04Z
Xhungab
23827
775212
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="c">
+1.570796327\
+0.000000000*cos(2.000000000* 1*x) -1.000000005*sin(2.000000000* 1*x)\
+0.000000000*cos(2.000000000* 2*x) -0.500000043*sin(2.000000000* 2*x)\
+0.000000000*cos(2.000000000* 3*x) -0.333333480*sin(2.000000000* 3*x)\
+0.000000000*cos(2.000000000* 4*x) -0.250000347*sin(2.000000000* 4*x)\
+0.000000000*cos(2.000000000* 5*x) -0.200000678*sin(2.000000000* 5*x)\
+0.000000000*cos(2.000000000* 6*x) -0.166667841*sin(2.000000000* 6*x)\
+0.000000000*cos(2.000000000* 7*x) -0.142859010*sin(2.000000000* 7*x)\
+0.000000000*cos(2.000000000* 8*x) -0.125002792*sin(2.000000000* 8*x)\
+0.000000000*cos(2.000000000* 9*x) -0.111115094*sin(2.000000000* 9*x)\
...
+0.000000000*cos(2.000000000* 45*x) -0.022863618*sin(2.000000000* 45*x)\
+0.000000000*cos(2.000000000* 46*x) -0.022433331*sin(2.000000000* 46*x)\
+0.000000000*cos(2.000000000* 47*x) -0.022027209*sin(2.000000000* 47*x)\
+0.000000000*cos(2.000000000* 48*x) -0.021644202*sin(2.000000000* 48*x)\
+0.000000000*cos(2.000000000* 49*x) -0.021283386*sin(2.000000000* 49*x)\
+0.000000000*cos(2.000000000* 50*x) -0.020943951*sin(2.000000000* 50*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
4g9aqviql6gh70z120tj8fv3pdqxw45
Mathc initiation/a616
0
81130
775234
774515
2026-10-13T19:43:19Z
Xhungab
23827
775234
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double omega = (2.*PI/T);
double a = -1;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="c">
+0.333333333\
-0.405284728*cos(3.141592654* 1*x) +0.636619776*sin(3.141592654* 1*x)\
+0.101321157*cos(3.141592654* 2*x) -0.318309914*sin(3.141592654* 2*x)\
-0.045031578*cos(3.141592654* 3*x) +0.212206684*sin(3.141592654* 3*x)\
...
+0.000150672*cos(3.141592654* 48*x) -0.013779127*sin(3.141592654* 48*x)\
-0.000141849*cos(3.141592654* 49*x) +0.013549424*sin(3.141592654* 49*x)\
+0.000133333*cos(3.141592654* 50*x) -0.013333333*sin(3.141592654* 50*x)
</syntaxhighlight>
{{AutoCat}}
nqfcer4dd3eeoabjj9i7rvvs0k0vse8
Fonctionnement d'un ordinateur/Les processeurs à émission dans l'ordre
0
81755
775171
730380
2026-10-13T14:40:04Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions]] vers [[Fonctionnement d'un ordinateur/L'émission des instructions]]
775171
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/L'émission des instructions]]
d4p8vhy6k0yw8y4houtklirkkdyx634
Fonctionnement d'un ordinateur/Les défauts des pipelines de longueur fixe
0
81756
775165
747339
2026-10-13T14:39:04Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775165
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Fonctionnement d'un ordinateur/Les pipelines de longueur fixe
0
81902
775167
747341
2026-10-13T14:39:24Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775167
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Fonctionnement d'un ordinateur/Les pipelines de longueur variable
0
81903
775169
730303
2026-10-13T14:39:44Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/L'émission dans l'ordre des instructions]] vers [[Fonctionnement d'un ordinateur/L'émission des instructions]]
775169
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/L'émission des instructions]]
d4p8vhy6k0yw8y4houtklirkkdyx634
Fonctionnement d'un ordinateur/Les pipelines de longueur fixe et dynamiques
0
82929
775168
747237
2026-10-13T14:39:34Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines multicycles]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775168
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Mathc initiation/007i
0
84565
775222
774497
2026-10-13T19:38:39Z
Xhungab
23827
775222
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2a0.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double a = -PI;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a, b);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_0 = 1/T int( (%s) dx = %.6f\n"
" (a \n\n\n\n", feq, a0);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_0 = 1/T int( ((t^2))) dx = 3.289868
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
tc3y1mykj68bjxtcbdbg33mj72j3d5s
Mathc initiation/007j
0
84566
775223
774498
2026-10-13T19:39:29Z
Xhungab
23827
775223
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2an.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double n = 1.; /* You can change the value of n */
double an = 2./T * fourier_an(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_%.0f = 2/T int( (%s) cos(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, an);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_1 = 2/T int( ((t^2))) cos(1 omega t) dt = -4.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
2zfnf1dixwc3tspw8yruscq4u3ffdpe
Mathc initiation/007k
0
84567
775224
774500
2026-10-13T19:39:53Z
Xhungab
23827
775224
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2bn.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double n = 1.; /* You can change the value of n */
double bn = 2./T * fourier_bn(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" b_%.0f = 2/T int( (%s) sin(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, bn);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
b_1 = 2/T int( ((t^2))) sin(1 omega t) dt = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
bgap3zqo7y4faoot66ypbjszinf8urj
Mathc initiation/007l
0
84568
775226
774507
2026-10-13T19:40:21Z
Xhungab
23827
775226
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 3.289868
+3.289868134\
-3.999999935*cos(1.000000000* 1*x) +0.000000000*sin(1.000000000* 1*x)\
+0.999999740*cos(1.000000000* 2*x) +0.000000000*sin(1.000000000* 2*x)\
-0.444443859*cos(1.000000000* 3*x) -0.000000000*sin(1.000000000* 3*x)\
+0.249998958*cos(1.000000000* 4*x) +0.000000000*sin(1.000000000* 4*x)\
-0.159998369*cos(1.000000000* 5*x) +0.000000000*sin(1.000000000* 5*x)\
+0.111108757*cos(1.000000000* 6*x) -0.000000000*sin(1.000000000* 6*x)\
-0.081629440*cos(1.000000000* 7*x) -0.000000000*sin(1.000000000* 7*x)\
+0.062495791*cos(1.000000000* 8*x) -0.000000000*sin(1.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
bcf15414qxkpask1s3ab3d24xgkgu7x
Mathc initiation/007m
0
84569
775227
774504
2026-10-13T19:40:38Z
Xhungab
23827
775227
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 3.289868
+3.289868134\
-3.999999935*cos(1.000000000* 1*x) +0.000000000*sin(1.000000000* 1*x)\
+0.999999740*cos(1.000000000* 2*x) +0.000000000*sin(1.000000000* 2*x)\
-0.444443859*cos(1.000000000* 3*x) -0.000000000*sin(1.000000000* 3*x)\
+0.249998958*cos(1.000000000* 4*x) +0.000000000*sin(1.000000000* 4*x)\
-0.159998369*cos(1.000000000* 5*x) +0.000000000*sin(1.000000000* 5*x)\
+0.111108757*cos(1.000000000* 6*x) -0.000000000*sin(1.000000000* 6*x)\
-0.081629440*cos(1.000000000* 7*x) -0.000000000*sin(1.000000000* 7*x)\
+0.062495791*cos(1.000000000* 8*x) -0.000000000*sin(1.000000000* 8*x)\
-0.049377372*cos(1.000000000* 9*x) +0.000000000*sin(1.000000000* 9*x)\
...
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
jo80e6nu5toxwncrf5ddwyosbuw83s4
Fonctionnement d'un ordinateur/Les pipelines multicycles
0
84571
775170
774597
2026-10-13T14:39:54Z
EmausBot
70429
Robot : Correction d’une double redirection depuis [[Fonctionnement d'un ordinateur/Les pipelines dynamiques]] vers [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
775170
wikitext
text/x-wiki
#REDIRECTION [[Fonctionnement d'un ordinateur/Le scoreboard et le ROB]]
oeziv8v9mbaidbz9zpdkj5vg8yvm42t
Fonctionnement d'un ordinateur/Les pipelines in-order
0
84572
775154
775086
2026-10-13T14:23:48Z
Mewtow
31375
/* Les registres d'échelonnement */
775154
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un '''pipeline dynamique''', de taille variable selon l'instruction. Avec un pipeline dynamique, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
svni23v7blaua5zh3bu391ljmlaj69g
775155
775154
2026-10-13T14:24:26Z
Mewtow
31375
/* Les blocages de pipeline */
775155
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
ljldk8slilcvxpt7a3faxoodgx3whwb
775156
775155
2026-10-13T14:26:57Z
Mewtow
31375
/* La solution des processeurs MIPS */
775156
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
mu42krpuoktr1ftldnbucdz1uumfmfa
775157
775156
2026-10-13T14:29:19Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
775157
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Si les écritures changent d'ordre, une instruction peut lire un opérande qui n'est pas la bonne, qui n'a pas été écrite par la bonne instruction. Le programme ne s'exécute pas correctement. De plus, les mécanismes d'exception précises cessent de fonctionner, car ils supposent que les écritures dans les registres se font dans l'ordre.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
jptsjymp8tvjynsj40ea47c421oj8nk
775158
775157
2026-10-13T14:33:03Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
775158
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance en question est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
t2qd65l9qqbmvn8tjtpetopivtn3mvs
775159
775158
2026-10-13T14:33:30Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
775159
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance se situe entre les deux instructions qui écrivent dans le même registre, elle est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée aux instructions mémoire, qui sont à part des autres. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
qq20osxkm6zcxy1f7lr1acrb4hpwxr0
775160
775159
2026-10-13T14:34:07Z
Mewtow
31375
/* L'inversion de l'ordre des écritures */
775160
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance se situe entre les deux instructions qui écrivent dans le même registre, elle est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée pour les multiplications. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
Les dépendances qui vont nous intéresser sont les '''dépendances de données'''. Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus. Il y a aussi les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
81xtsb395sn5tyiez06sh3aafw2pnep
775161
775160
2026-10-13T14:35:39Z
Mewtow
31375
/* Les dépendances d'instruction */
775161
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance se situe entre les deux instructions qui écrivent dans le même registre, elle est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée pour les multiplications. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
* Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus.
* Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions, qui fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
* Les '''dépendances de données''' sont celles qui vont nous intéresser pour ce chapitre et les suivants.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
2kceesoj2yxlkch79q9zls8yyocxp0v
775162
775161
2026-10-13T14:36:36Z
Mewtow
31375
/* Les dépendances d'instruction */
775162
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance se situe entre les deux instructions qui écrivent dans le même registre, elle est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée pour les multiplications. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''Result Shift Register'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''result shift register'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''result shift register''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''result shift register'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''result shift register'' a été décalé, ce qui donne :
{|class="wikitable"
|+ Result Shift Register
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
* Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus.
* Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
* Les '''dépendances de données''' sont celles qui vont nous intéresser pour ce chapitre et les suivants.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
8vksvjv52zakba4ans69q0bsq7ysiel
775205
775162
2026-10-13T19:25:55Z
Mewtow
31375
775205
wikitext
text/x-wiki
Avec ce qu'on a dit dans le chapitre précédent, il semblerait que l'implémentation d'un pipeline soit assez simple. Mais dans la réalité, un pipeline doit gérer des situations assez peu intuitives, qui grippent sa belle mécanique bien huilée. Un pipeline est un peu l'équivalent d'une chaine de montage, avec une séquence d'étapes à exécuter dans un ordre bien précis. Mais quelques évènements peuvent gripper cette mécanique. Il arrive qu'un étage soit bloqué pendant plusieurs cycles, pour des raisons qu'on va expliquer dans ce chapitre.
Dans ce chapitre, nous allons voir les '''processeurs ''in-order'''''. Les définir n'est pas facile, surtout à ce stade du cours. Ils sont opposés aux processeurs avec exécution dans le désordre, qu'on verra dans quelques chapitres. La différence est ce qui se passe à la fin du pipeline. Un pipeline charge une nouvelle instruction par cycle, et celle-ci se propage dans le pipeline au rythme d'un étage par cycle d'horloge. On démarre les instructions une par une, dans un certain ordre. Mais le parcours du pipeline n'est pas un long fleuve tranquille, comme on le verra dans ce chapitre, et le résultat à la fin du pipeline est parfois différent.
À la fin du pipeline, il se peut que des instructions arrivent dans le désordre. Une instruction a été bloquée pendant quelques cycles dans un étage, les autres ont continué de leur côté, les problèmes sur le chemin sont nombreux, comme on l'expliquera dans le reste du chapitre. Au final, deux instructions peuvent théoriquement arriver dans le désordre. Du moins, ce serait le cas si le processeur n'était pas conçu pour corriger le problème.
Les processeurs ''in-order'' garantissent que des instructions chargées dans un certain ordre termineront dans le même ordre. Cela a de nombreux avantages, l'un d'entre eux est que cela permet au système exceptions précises, vu dans les chapitres précédents, de fonctionner au mieux. Les ''processeurs à exécution dans le désordre'', eux, acceptent que les instructions se terminent dans le désordre, pour des raisons de performance. Mais ils ont des systèmes pour éviter que cela pose problème. Ils utilisent pour cela des systèmes d'exception précises plus élaborés que ceux des chapitres précédents. Mais cela sera pour un autre chapitre. Pour ce chapitre, nous allons nous concentrer sur les processeurs ''in-order''.
Mais avant toute chose, un petit rappel. Un processeur avec un pipeline est décomposé en deux parties. Le ''front end''), prend en charge les étages communs à toutes les instructions, comme le chargement et le décodage. L'amont est suivi une unité d'émission, elle-même suivie par le chemin de données. Le chemin de données contient plusieurs unités différentes : des unités de calcul, l'unité mémoire, parfois des unités pour les branchements.
[[File:Unité d'émission.png|centre|vignette|upright=2|Unité d'émission]]
Avec des instructions multi-cycles, le pipeline exploite plusieurs unités en même temps, c'est même la norme. Le processeur peut donc exécuter plusieurs instructions en parallèle, alors qu'elles ont été démarrées à des cycles différents. J'insiste sur ce point : le processeur émet une instruction par cycle, il en démarre une seule par cycle. Le processeur ne peut exécuter plusieurs instructions en même temps, que s'il a démarré une instruction multi-cycle. Le processeur exécute des opérations simples dans l'ALU entière, en parallèle de l'instruction multi-cycle. Néanmoins, cette possibilité fait face à de nombreuses contraintes. Et le reste du chapitre vise à expliquer quelles sont ces contraintes.
==L'inversion de l'ordre des écritures==
Démarrer une nouvelle instruction par cycle est la base du pipeline. Mais avec des instructions multi-cycles, un problème bien particulier survient. Le processeur exécute plusieurs instructions en même temps, dans le même étage d’exécution, mais dans des ALUS/multiplieurs/FPU séparés. Le problème, c'est que si les instructions démarrent dans l'ordre du programme, les unes après les autres, elles peuvent terminer dans le désordre.
Par exemple, prenons l'exemple d'une multiplication prenant 6 cycles, suivie par une addition s’exécutant en un cycle. Le résultat est illustré ci-dessous : l'ordre d'enregistrement des résultats des instructions s'est inversé ! Les instructions sont émises une par une, dans l'ordre, mais l'enregistrement des résultats dans les registres se font dans le désordre. On dit qu'il y a '''inversion de l'ordre des écritures'''. Et cette inversion cause de nombreux problèmes.
Premier problème : le système d'exception précise cesse de fonctionner si les écritures se font dans le désordre. Le principe est exceptions précises est que les instructions s'exécutent dans l'ordre du programme, et écrivent leurs résultats dans l'ordre du programme. Si une instruction lève une exception, ou qu'une mauvaise prédiction de branchement a lieu, le système doit interdire les instructions suivantes d'écrire dans les registres. Cela suppose que les écritures se font dans l'ordre du programme, ce que l'inversion de l'ordre des écritures viole complètement.
Deuxième problème : des registres peuvent avoir une valeur invalide. Le problème survient quand deux instructions "proches" écrivent dans le même registre, mais pas en même temps. Il se peut qu'une instruction lise une opérande dans ce registre, et elle doit lire la bonne. Si les écritures changent d'ordre, l'opérande lu n'a pas été écrit par la bonne instruction, son résultat sera invalide. Pour résoudre le problème, il n'y a qu'une seule solution : les écritures dans un même registre doivent se faire l'ordre du programme, pour garantir que les instructions lisent les bonnes opérandes. Le problème est ici que les deux instructions ne sont pas indépendantes, vu qu'elles agissent sur le même registre. La dépendance se situe entre les deux instructions qui écrivent dans le même registre, elle est appelée une '''dépendance ''Write After Write'''''.
[[File:Exemple de problème survenant avec des pipeline de longueur variable.png|centre|vignette|upright=2|Exemple de problème survenant avec des pipeline de longueur variable]]
Maintenant, autre problème, qui peut être vu comme un cas particulier du précédent. Imaginons qu'une multiplication prenne deux cycles, et l'addition prenne 1 cycle. Prenons une multiplication suivie par une addition. La multiplication commence à s'exécuter au cycle numéro 1, termine son exécution au cycle 2, puis enregistre son résultat au cycle 3. L'addition s’exécute au cycle 2, et enregistre son résultat au cycle 3. Les deux instructions enregistrent leur résultat au même cycle, ce qui n'est pas possible, vu que le banc de registre a un seul port d'écriture. Il y a un phénomène de '''double écriture simultanées'''. Et s'il s'agit là d'un exemple qui mélange dépendance structurelle et dépendance WAW.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! ...
|-
| Multiplication || Multiplication || Multiplication ||class="f_bleu" | Écriture || ||
|-
| || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Il y a deux solutions principales pour régler ce problème, avec une troisième solution spécialement adaptée pour les multiplications. La première méthode permet d'utiliser des instructions multi-cycles, avec un pipeline fixe. La seconde utilise soit un pipeline fixe, soit un pipeline dynamique, mais avec des restrictions assez importantes. La gestion des défauts de cache est elle un cas à part, valable sur tous les types de pipelines.
===Les registres d'échelonnement===
La première solution est assez brute de décoffrage. Elle force toutes les instructions à avoir exactement le même nombre de cycles d'horloge, le même nombre d'étages. Un tel pipeline est appelé un ''pipeline de longueur fixe'', ou encore un '''pipeline fixe'''. Et le pipeline se cale sur les instructions les plus lentes. Par exemple, si une addition prend un cycle d'horloge, qu'une multiplication en prend 8 et qu'un décalage en prend 2, alors toutes les instructions prendront 8 cycles d'horloge. Et ces 8 cycles d'horloge correspondront à 8 étages d'exécution.
[[File:Processeur à pipeline fixe qui gére des instructions multicycles.png|centre|vignette|upright=3|Processeur à pipeline fixe qui gére des instructions multi-cycles]]
En conséquence, les instructions plus rapides auront donc des étages qui ne font rien. L'instruction doit passer par ces étages, mais ceux-ci ne doivent rien faire. Pour cela, on insère des registres entre la sortie de l'ALU et le banc de registre. Les registres en question sont appelés des ''staggering registers'', mais nous préférerons la traduction '''registres d'échelonnage'''. Il y a autant de registres d'échelonnage que cycles de retard à ajouter, car chacun retarde le résultat d'un cycle d’horloge. Pendant les cycles de retard, le résultat sera passé d'un registre d'échelonnage à l'autre, sans que rien ne se passe.
[[File:Regsitres d'échelonnage.png|centre|vignette|upright=2|Registres d'échelonnage]]
Un défaut de la méthode précédente est que les données sont copiées d'un registre de pipeline au suivant à chaque cycle d'horloge, ce qui consomme de l'énergie pour rien. Pour éviter cela, les processeurs ''in order'' modernes regroupent les registres d'échelonnage dans un banc de registre séparé, appelé le '''banc de registres d'échelonnage'''. Lors du décodage de l'instruction, un registre est attribué à l'instruction pour mémoriser son résultat, si besoin. Le résultat est enregistré dans le registre alloué en sortant de l'unité de calcul. Puis, il est copié dans le banc de registre architectural dès que le délai nécessaire est écoulé.
[[File:Banc de registres d'échelonnage.png|centre|vignette|upright=2|Banc de registres d'échelonnage]]
===Les blocages de pipeline===
Une solution alternative n'utilise ni registres d'échelonnage, ni étages qui ne font rien. Elle utilise à la place un pipeline de taille variable selon l'instruction. Avec un pipeline variable, certaines instructions peuvent prendre 1 cycle, d'autres 7 cycles, d'autres 9, d'autres 25, etc. Les instructions rapides prennent moins de cycles que les autres. Les instructions multi-cycles prennent le temps qu'il faut pour faire leur calcul, les additions donnent leur résultat en un seul cycle, bref : les instructions ont des temps d'exécution variables. On ne se cale pas sur la plus lente. De plus, les instructions écrivent dans les registres au plus tôt, dès qu'elles ont fini leur calcul.
[[File:Pipeline avec un nomrbe variable d'étages par instructions.png|centre|vignette|upright=3|Pipeline avec un nombre variable d'étages par instructions.]]
Pour éviter d'inverser les écritures, ce sont les instructions qui sont retardées. Pour comprendre comment ça marche, prenons l'exemple d'une multiplication suivie d'une addition, encore. La multiplication prend 8 cycles et occupe l'étage d'exécution pendant 8 cycles consécutifs. Pendant ces 8 cycles, l'instruction suivante attend dans l'unité de décodage, elle n'entre pas dans le chemin de donnée. Sa progression dans le pipeline est stoppée, et même que toutes les instructions précédentes. Elle ne redémarre que quand cela garantit que son écriture se fait après l'écriture de la multiplication.
[[File:Pipeline bubble.png|centre|vignette|upright=2|''Pipeline bubble''.]]
Cette solution ''retarde les instructions'' pour qu'elles écrivent leurs résultats dans l'ordre. En clair, on retarde la seconde instruction histoire de remettre les écritures dans l'ordre. Lors de cette mise en attente, le pipeline est bloqué, mais seulement pour les étages précédant l'unité d'émission. Il s'agit donc d'un '''blocage sélectif''' du pipeline,n un blocage partiel.
L'ordre de bloquer le pipeline est généré par l'unité d'émission. Dès qu'on doit bloquer le pipeline, c'est elle qui s'en charge, à une exception près sur laquelle nous reviendrons. L'unité d'émission envoie un signal ''pipeline stall'', qui gèle les registres de pipelines, pour les étages qui précédent l'unité d'émission. Le signal est maintenu tant que l'instruction multicycle s'exécute (à quelques cycles près). Reste à voir comment elle prend sa décision.
[[File:Implémentation d'un blocage pipeline lors d'une opération multi-cycles.png|centre|vignette|upright=2.5|Implémentation d'un blocage pipeline lors d'une opération multi-cycles]]
Une implémentation basique bloque le pipeline quand une instruction multi-cycles est en cours d'exécution. Pour cela, l'unité d'émission contient un compteur, qu'il initialise quand il démarre une micro-opération multi-cycles. Le compteur est initialisé avec le nombre de cycles d'horloge passés dans l'unité de calcul. Une nouvelle micro-opération peut démarrer si ce compteur est à zéro, le pipeline est bloqué sinon.
Cependant, les performances sont grandement en-deças de ce qu'on pourrait espérer. En effet, la micro-opération candidate est émise avec un retard largement plus grand que nécessaire. Par exemple, imaginons la situation illustrée dans le tableau ci-dessous. Une multiplication, suivie d'une addition qui écrase ce résultat au cycle suivant. Cas complètement irréaliste, mais passons. L'addition prend deux cycles, la multiplication en prend 5. Sans pipeline, voici ce que ça donne :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || Addition || Addition || class="f_bleu" | Écriture ||
|}
Maintenant, ajoutons un pipeline, et utilisons l'unité d'émission décrite plus haut, qui retarde la seconde instruction tant que la première n'est pas terminée. Avec cette implémentation basique, l'addition est retardée tant que la multiplication n'a pas écrit son résultat dans le registre. Les cycles de retard sont indiqués en rouge.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! ...
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Écriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Alors qu'en réalité, une solution optimale serait celle-ci :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Pour obtenir le cas optimal, le processeur utilise un '''''registre de réservation'''''. Il s'agit d'un registre à décalage, dont le nombre de bits se cale sur l'instruction la plus longue. Par exemple, si le processeur exécute des additions en deux cycles, des décalages en trois cycles et des multiplications en 6 cycles, alors le ''registre de réservation'' aura 6 bits. Fait très important : on suppose que l'étage de ''writeback'' est comptée dans ces cycles. Et il en est de même pour les étages situés après ou avant l'étage d'exécution, du moment qu'ils suivent l'étage d'émission/le décodeur.
Quand une instruction est émise, elle réserve le cycle d'horloge où elle compte faire une écriture. Les autres instructions ne pourront pas faire d'écriture lors d'un cycle réservé. Cela résout le problème des ''double écriture simultanées''. Pour réserver un cycle d'horloge, elle met le bit correspondant à 1, dans le ''registre de réservation''. Et on peut étendre la méthode pour gérer les dépendances WAW.
Une instruction future ne doit pas faire d'écriture avant la micro-opération candidate. Pour cela, elle va empêcher les futures instructions de réserver les cycles précédents, ceux avant le cycle réservé. En clair : elle met les bits correspondant à 1. S'ils le sont déjà, ce n'est pas un problème : ils étaient à 1, ils restent à 1. les réservations faites par les instructions passées ne sont pas écrasées. Ainsi, une instruction qui s'exécute en N cycles réservera N étages de pipeline, N cycles consécutifs.
{|class="wikitable"
|+ registre de réservation
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || 1 || 1 || 1 || 1
|}
À chaque cycle d'horloge, ce registre est décalé d'un cran vers la droite. Cela permet de prendre en compte le fait que chaque instruction progresse d'un étage à chaque cycle d'horloge. Il va de soi que ce décalage est un décalage logique, qui remplit les vides par des zéros.
Lorsque l'unité d'émission reçoit une instruction, elle vérifie le nombre de cycles qu'elle prend pour s’exécuter. Le nombre de cycles en question permet de savoir quel bit consulter dans le ''result shift''. Pour une instruction qui prend N cycles, elle vérifie si le bit numéro N de ce registre (en partant de la droite). S’il est à 1, cela signifie qu'une autre instruction a déjà réservé cet étage de pipeline. L'instruction candidate va devoir attendre que cet étage soit libre. Si ce bit est à 0, l’unité réserve alors le cycle associé en mettant le bit à 1, ainsi que tous les bits précédents, puis elle est émise.
Par exemple, si je prends le cas du ''registre de réservation'' du tableau précédent. Imaginons qu'une instruction veuille s’exécuter pendant 4 cycles. L'unité d'émission regarde le quatrième bit. Elle voit qu'il est à 1, ce qui fait que ce n'est pas possible. Elle est alors retardée de un cycle.
{|class="wikitable"
|+ registre de réservation
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 1 || 1 || 1 || 1
|}
Après le retard, le ''registre de réservation'' a été décalé, ce qui donne :
{|class="wikitable"
|+ registre de réservation
|-
! Cycle t+1 !! Cycle t+2 !! Cycle t+3 !! Cycle t+4 !! Cycle t+5 !! Cycle t+6 !! Cycle t+7
|-
| 0 || 0 || 0 || class="f_rouge" | 0 || 1 || 1 || 1
|}
Le bit associé vaut 0, ce qui fait que l'instruction peut être émise. Elle met alors ce bit à 1, ainsi que tous les précédents (ils le sont déjà).
===La solution des processeurs MIPS===
Une solution alternative a été utilisée sur les premiers processeurs MIPS. Ils utilisaient un pipeline RISC classique modifié pour supporter des instructions multi-cycles, à savoir des multiplications. Les multiplications étaient les seules instructions multi-cycles sur ces processeurs. Les instructions multi-cycles disposaient de leur propre registres rien que pour elles. Précisément, les instructions de multiplication enregistraient leur résultat dans des registres LO et HI, pas dans les registres généraux.
Le fait de séparer les registres résout ce problème d'inversion des écritures. L'inversion des écritures survient quand on mixe des instructions avec des temps d'exécution différents, mais qui écrivent dans les mêmes registres. Ici, on sépare les registres et les instructions suivant leurs temps d'exécution. Les multiplications étaient émises dans l'ordre, avaient toute le même temps d'exécution : les résultats arrivaient forcément en ordre dans les registres HI/LO. Idem pour les autres instructions qui prenaient un seul cycle : elles étaient émises dans l'ordre, prenaient toutes un cycle, et enregistraient leurs résultats dans l'ordre dans les registres généraux.
Le processeur avait des instructions pour copier le résultat d'une multiplication dans les registres généraux. Et c'est à ce moment là que l'ordre des écritures intervenait. Cependant, ce problème d'ordre des écritures était transformée en autre chose, une dépendance de données de type RAW, qui est justement le sujet de la section suivante.
==Les dépendances d'instruction==
Les problèmes liés aux instructions multi-cycles sont des cas particuliers d'un problème plus général, que tous les pipelines ont. Un programme est codé en partant du principe que les instructions s'exécutent de manière séquentielle, l'une après l'autre. Une instruction n'est démarrée que lorsque la précédente est terminée. Mais ce n'est pas le cas sur un pipeline, qui charge une nouvelle instruction à chaque cycle, sans attendre que la précédente soit terminée. Le pipeline ne pose pas de problèmes si deux instructions consécutives sont indépendantes. Mais en pratique, les instructions ont des dépendances très variées.
* Nous avons déjà parlé des '''dépendances structurelles''' au chapitre précédent, aussi je ne reviendrais pas dessus.
* Les '''dépendances de contrôle''' sont le fait des branchements, interruptions et exceptions. Elles ont fait l'objet des chapitres sur la prédiction de branchement, et d'une section sur les exceptions précises.
* Les '''dépendances de données''' sont celles qui vont nous intéresser pour ce chapitre et les suivants.
===Les dépendances de données : registres et adresses===
Deux instructions ont une dépendance de données quand elles accèdent (en lecture ou écriture) à la même donnée, c’est-à-dire au même registre ou la même adresse mémoire. Et cela nous amène à distinguer deux types de dépendances : de registre, d'adresse.
Les '''dépendances de registre''' surviennent quand deux instructions lisent ou écrivent le même registre. Le cas typique est celui où une instruction lit un registre et qu'une autre écrit dedans, mais il faut aussi tenir compte du cas avec deux écritures dans le même registre. Elles concernent toutes les instructions, ou presque, que ce soit des instructions de calcul, des instructions mémoire, les branchements, et bien d'autres.
Les '''dépendances d'adresse''' sont par contre spécifiques pour les instructions mémoire. Elles surviennent quand deux instructions mémoire lisent/écrivent dans une même adresse mémoire. Et cela implique d'avoir deux instructions mémoire en vol, ce qui implique que le pipeline accepte plusieurs accès mémoire "simultanés". Et cette possibilité n'est pas réservée aux pipelines complexes. Nous verrons d'ailleurs un exemple frappant dans la fin de ce chapitre. Mais il faut avouer que les dépendances d'adresse ne posent problème que sur les processeurs dotés d'exécution dans le désordre, qui seront vus dans plusieurs chapitres. Aussi, pour le moment, concentrons-nous sur les dépendances de registres.
Les dépendances de registre surviennent quand deux instructions lisent ou écrivent un registre. Deux instructions, deux types d'accès (lecture ou écriture), cela fait quatre possibilités :
* '''Read after read''' : Deux instructions lisent le même registre, mais pas en même temps.
* '''Read after write''' : Une instruction lit le résultat écrit par l'autre.
* '''Write after write''' : Deux instructions écrivent dans le même registre, mais pas en même temps.
* '''Write after read''' : Une instruction lit une donnée qui est modifiée ultérieurement par une autre instruction.
Les dépendances ''Read after read'' ne sont pas de vraies dépendances et je les présente par pur souci d'exhaustivité. Par contre, les trois autres dépendances sont bloquantes. Dans le pire des cas, elles imposent que la première instruction termine son exécution, avant que la seconde soit démarrée. Pour simplifier les explications, nous allons appeler ces trois dépendances : dépendances RAW, WAR et WAW.
Les dépendances WAR n'existent tout simplement pas sur la plupart des pipelines. Concrètement, elles n'apparaissent que sur les processeurs dits à exécution dans le désordre, et encore, seulement si on n'utilise pas de renommage de registre, ce qui n'est plus le cas depuis des décennies.
Les dépendances WAW existent à condition que le pipeline supporte les écritures dans le désordre. Or, nous avons vu plus haut comment remettre les écritures dans l'ordre. Les pipelines que nous avons abordés ne sont pas concernés par les dépendances WAW. Aussi, nous repoussons leur étude aux processeurs avec exécution dans le désordre.
===L'unité d’émission : la détection des dépendances de registres===
Un point important est que la gestion des dépendances est différente entre les dépendances de registre et les dépendances d'adresse. Détecter une dépendance de registre demande de comparer les registres utilisés par deux instructions, ce qui peut être fait après l'unité de décodage. Par contre, détecter les dépendances d'adresse demande de comparer les adresses lues/écrites, qui sont souvent calculées/déterminées à l'exécution. Les deux types de dépendances sont donc le fait de circuits séparés, dans des étages de pipeline distincts.
La gestion des dépendances d'adresse est le fait de l'unité mémoire, les autres dépendances sont gérées dans une '''unité d'émission''', séparée du décodeur d'instruction, qui a son propre étage de pipeline. L'unité d'émission détecte les dépendances de registres et met en attente les micro-opérations si besoin. Pour simplifier, c'est elle bloque le pipeline quand c'est nécessaire. Et mine de rien, la section sur les instructions multi-cycle nous a montré son rôle. C'est elle qui gèle le pipeline lors d'une instruction multi-cycle. Par contre, elle ne gère pas les défauts de cache, qui sont gérés par l'unité mémoire.
{|class="wikitable"
|+ Pipeline basique, avec une unité d'émission
|-
! Calcul du ''Program Counter'' !! Chargement !! Décodage !! class="f_rouge" | Émission !! Lecture des opérandes (registres) !! Exécution (ALU) !! ''Writeback''
|-
| rowspan="2" | ''Program Counter''
| rowspan="2" | Cache d'instruction
| rowspan="2" | Décodeur d'instruction
| rowspan="2" class="f_rouge" | Unité d'émission
| rowspan="2" | Port de lecture du banc de registre (registres)
| Unités de calcul
| rowspan="2" | Port d'écriture du banc de registre
|-
| class="f_rouge" | Unité mémoire
|}
À chaque cycle, l'unité d'émission reçoit une micro-opération et analyse ses dépendances de données. Si elle a une dépendance bloquante, elle est mise en attente dans l'étage d'émission, en attendant que les conditions soient réunies pour pouvoir s'exécuter. Durant ce temps d'attente, le pipeline est bloqué. Sinon, l'unité d'émission la laisse passer à l'étage suivant.
Commençons par étudier les dépendances RAW, où une instruction a comme opérande le résultat d'une instruction précédente. L'instruction qui produit le résultat est appelée l''''instruction productrice''', alors que celle qui l'utilise est appelée l''''instruction consommatrice'''. Les deux instructions ont alors une dépendance de donnée, dite de type '''''Read After Write'''''. Et cette dépendance impose que l'instruction productrice soit totalement terminée avant qu'on émette l'instruction consommatrice.
La raison est que le résultat de l'instruction productrice doit être enregistré dans les registres, avant d'être utilisé comme opérande. Et cette écriture a lieu à la fin du pipeline, ce qui fait qu'il y a quelques cycles entre le moment où le résultat est calculé et enregistré. Du fait de ce délai, il se peut que l'instruction consommatrice lise le registre avant que le résultat soit enregistré. Si l'instruction consommatrice est lancée immédiatement après l'instruction productrice, ça arrivera avec certitude.
Pour éviter cela, il suffit de retarder l'exécution de l'instruction consommatrice tant que le résultat de l'instruction productrice n'est pas disponible, pas encore dans les registres. Et il se peut qu'elle soit retardée durant quelques cycles pour cela. Et ce retard implique, vous l'aurez deviné, de bloquer le pipeline.
Précision importante : les dépendances RAW ne sont pertinentes que pour les micro-opérations en vol, celles qui sont dans le chemin de données. Si une instruction a une dépendance avec une autre, mais que les deux ne sont pas dans le pipeline en même temps, la dépendance n'a aucune conséquence. Les dépendances ne posent un problème que si elles concernent deux instructions qui sont en vol.
Mais comment le processeur détecte les dépendances de registres entre deux instructions ? La réponse est simple : il compare les registres utilisés par les deux instructions. Les registres étant connus dès la sortie de l'étape de décodage, l'unité de décodage peut les comparer. L'unité d'émission reçoit une micro-opération décodée, appelée '''micro-opération candidate'''. Elle détecte si cette micro-opération candidate a une dépendance avec les micro-opérations qui sont dans le pipeline, dans les étages suivant l'unité d'émission. Les micro-opérations en question seront appelées '''micro-opérations "en vol"'''.
L'unité d'émission compare les registres utilisés par la micro-opération candidate et les micro-opérations en vol. Il n'y a pas de dépendance si ces registres sont différents, alors qu'il y a dépendance dans le cas contraire. Pour être plus précis, il faut cependant faire une différence entre les registres opérandes et de destination. Les '''registres opérandes''' sont ceux qui sont lus, ceux où sont les opérandes. Le '''registre de destination''' d'une instruction est celui dans lequel on enregistre le résultat.
Une dépendance RAW a lieu entre une instruction consommatrice et une instruction productrice. L'instruction productrice écrit son résultat dans un registre, son registre de destination. L'instruction consommatrice utilise ce résultat comme opérande, ce qui fait que le résultat est dans un de ses registres opérande. Une dépendance RAW implique que l'instruction productrice est en vol, alors que l'instruction consommatrice est candidate. L'unité d'émission doit donc comparer les registres opérandes de l'instruction candidate, avec les registres de destination des instructions en vol.
L'unité d'émission doit donc connaitre les registres de destination des micro-opérations en vol, ce qui peut se faire de plusieurs manières. La première méthode utilise le fait que les noms de registres sont propagés dans le pipeline, comme tous les signaux de commande. Dans ces conditions, rien n’empêche de relier les registres tampons chargés de la propagation à l'unité d'émission. L'unité d'émission est donc un paquet de comparateurs reliés par des portes OU. En sortie, elle fournit un signal STALL, qui indique s'il faut bloquer le pipeline ou non. Pour un pipeline à N étages maximum, cela demande de faire N comparaisons.
[[File:Détection des dépendances dans un pipeline, sans scoreboard.png|centre|vignette|upright=2|Détection des dépendances dans un pipeline.]]
Une autre implémentation, beaucoup plus économe en circuits, se limite à un simple '''registre de réservation'''. Il permet à une instruction de réserver en écriture son registre de destination. Par réservé, on veut dire que le registre ne peut pas être lu par une autre instruction. Les registres qui ne sont pas réservés sont dits libres ou disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, ce qui implique que c'est le registre de destination d'une instruction en vol. L'instruction n'est pas émise et le pipeline est bloqué.
Le registre encode les registres réservés en représentation ''one-hot'' modifiée. Chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Un registre est réservé en mettant le bit associé à 1.
{|class="wikitable"
|+ Registre de réservation
|-
! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0
|-
| 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0
|}
Il existe une seconde possibilité, où les registres réservés sont à 0 et les registres libres à 1. Nous l’appellerons le '''registre de disponibilité'''.
Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par l'instruction, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si l'instruction peut être émise.
[[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]]
Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. La mise à jour du registre de réservation à l'émission est simple : on prend le registre de destination, encodé en ''one-hot'', et on le combine avec le registre de réservation, en faisant un simple OU logique. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de l'instruction, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand l'instruction quitte l'unité de calcul.
Diverses optimisations permettent de mitiger l'impact des dépendances RAW. Et dans la section suivante, nous parlerons de la technique du contournement, qui permet de ne pas bloquer le pipeline pour les dépendances RAW.
==Le contournement : réduire les dépendances==
Nous venons de voir que les dépendances imposent de bloquer le pipeline, pendant quelques cycles. Et encore une fois, c'est l'unité d'émission qui s'en charge. Du moins, c'est le cas sans optimisation. Une optimisation permet de fortement réduire les blocages de pipelines liés aux dépendances RAW, jusqu'à les annuler dans certains cas. L'optimisation en question est celle du '''contournement''' (''bypass'') rend le résultat utilisable directement dans le pipeline, avant d'être enregistré dans les registres.
[[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]]
Le contournement fait que deux instructions consécutives avec une dépendance RAW peuvent quand même être émises. Mais comme on va le voir, cela n'élimine pas les blocages de pipelines liés aux dépendances RAW. L'unité d'émission existe toujours, elle n'est pas forcément simplifiée, au contraire. Par contre, les blocages de pipeline sont moins fréquents, et les performances augmentent.
: Avant de poursuivre, rappelons le vocabulaire. Si une instruction produit un résultat, qui utilisé par une seconde instruction comme opérande : la première instruction est appelée l''''instruction productrice''', la seconde est appelée l''''instruction consommatrice'''.
===Le contournement de l'unité de calcul===
La technique du contournement la plus simple implique uniquement l'unité de calcul. Elle permet à un résultat en sortie de l'unité de calcul d'être réutilisé immédiatement en entrée.
[[File:Data Forwarding (One Stage).svg|centre|vignette|upright=1.5|Principe du contournement avec le pipeline RISC classique.]]
Le résultat d'une opération est mémorisé en sortie de l'ALU, et précisément dans le registre de pipeline, au cycle suivant. Le contournement connecte ce registre de pipeline sur l'entrée de l'ALU si besoin, et on le déconnecte sinon. La connexion/déconnexion se fait avec des multiplexeurs.
[[File:Bypassing avec des multiplexeurs.png|centre|vignette|upright=2|Contournement avec des multiplexeurs.]]
Intuitivement, on se dit que c'est l'unité d'émission qui commande le multiplexeur. Mais il est possible de déporter cette fonction en-dehors de l'unité d'émission, de gérer cela au niveau "local". La commande du multiplexeur est réalisée par un circuit situé pas loin de l'ALU elle-même. L'unité d'émission émet juste deux micro-opérations consécutives qui ont une dépendance RAW, à condition que le résultat de l'une soit immédiatement consommé par l'autre, et que les deux utilisent l'ALU entière. Pour commander les multiplexeurs, il faut comparer le registre destination avec le registre source en entrée : si ce registre est identique, on devra faire commuter le multiplexeur pour relier la sortie de l'unité de calcul.
[[File:Implémentation complète du contournement.png|centre|vignette|upright=2|Implémentation complète du contournement]]
La présence de plusieurs unités de calcul complexifie le contournement. Un processeur a typiquement une ALU entière, un ''barrel shifter'' et un multiplieur entier. Intuitivement, on se dit qu'il faut envoyer la sortie de chaque circuit à la fois sur son entrée, mais aussi sur l'entrée des deux autres. En tout, cela demande 9 interconnexions, avec autant de multiplexeurs. Et c'est la même chose à l'intérieur d'une FPU. Pour rappel une FPU est typiquement composée de deux circuits : un additionneur flottant et un multiplieur flottant. Non seulement ils ont leur sortie connectée à leur entrée, mais en plus, la sortie de l'un est relié à l'entrée de l'autre.
En théorie, on devrait envoyer les résultats flottants à l'ALU entière et les résultats entiers à la FPU. Mais dans les faits, cela ne servirait pas à grand-chose. Il est rare que les instructions entières et flottantes échangent des données. Et si elles le font, elles peuvent passer par des copies entre registres, des registres flottants vers entiers et inversement. Aussi, le contournement entre ALU entière et FPU n'est presque jamais implémenté. Le processeur utilise alors deux réseaux de contournement : un entre les ALUs entières, un entre FPUs.
[[File:Réseau de contournement avec plusieurs ALU.png|centre|vignette|upright=2|Réseau de contournement avec plusieurs ALU]]
Il faut aussi penser au cas où les ALU entières calculent des adresses mémoire. Dans ce cas, il faut aussi connecter une unité de calcul à l'unité mémoire. Le ''barrel shifter'' et le multiplieur ne sont pas concernés, seule l'ALU entière capable de faire des additions l'est. La connexion est unidirectionnelle, de l'ALU entière vers l'unité mémoire.
Si l'unité mémoire incorpore sa propre unité de calcul, il faut faire de même, mais pas pour les mêmes raisons. Le réseau de contournement ne transmet alors pas des adresses mémoire calculées, mais les opérandes de ces calculs. Ces opérandes viennent soit des registres, soit sont calculées dans l'ALU entière. Mais le réseau de contournement est alors plus complexe, car toutes les ALU entières peuvent calculer ces opérandes. En pratique, c'est rarement fait.
L'ensemble des interconnexions pour le contournement s'appelle le '''réseau de contournement'''. Il peut être très complexe, proche d'une interconnexion totale (toutes les sorties sur toutes les entrées) ou au contraire réduit au minimum. Concevoir un réseau de contournement demande de vérifier quelles interconnexions possibles sont vraiment utiles. Il est des interconnexions qui sont inutiles, d'autres qui sont utiles dans des cas assez rares, d'autres qui ne sont tout simplement pas nécessaires.
===Le contournement des lectures mémoire===
La technique précédente gère les dépendances RAW pour les instructions de calcul uniquement. En clair, elle gére le cas où le résultat d'une instruction de calcul est utilisé comme opérande par une instruction suivante. Mais on peut gérer le cas où l'opérande est lu en mémoire. Une telle situation survient quand une lecture lit une donnée qui sert d'opérande à une instruction arithmétique. Elle survient aussi sur les architectures CISC, pour gérer les instructions de type ''load-op'', qui fusionnent une lecture et une opération en une seule instruction (le pipeline de tels processeurs est assez compliqué).
Une lecture lit une donnée, qui est utilisée comme opérande par d'autres instructions, généralement des instructions de calcul, parfois d'autres lectures : appelons-les les '''instructions lecture-dépendantes''', sous-entendu dépendantes du résultat de la lecture. Le contournement permet d'utiliser le résultat de la lecture avant qu'il soit enregistré dans le banc de registre.
Et cela demande de fortement modifier le pipeline. Précisément, il faut rajouter une interconnexion qui part de l'unité d'accès mémoire et se connecte sur l'entrée de l'ALU. Le tout est très similaire au contournement de l'unité de calcul et demande juste d'ajouter une entrée sur le multiplexeur, sur laquelle on relie la sortie de l'unité de lecture/mémoire.
[[File:Implémentation du contournement de l'unité mémoire.png|centre|vignette|upright=2|Implémentation du contournement de l'unité mémoire]]
La seule complexité est de commander le multiplexeur. Pour cela, on rajoute un second comparateur, qui compare le registre opérande avec le registre de destination fournit par l'unité mémoire. Il y a donc au total deux comparateurs, dont les résultats sont combinés pour commander le multiplexeur. Les comparateurs et le circuit de combinaison sont souvent regroupés dans un seul circuit appelé le '''l'unité de contournement'''. D'autres processeurs ont des unités de contournement plus élaborée, mais dont le principe est le même : comparer les registres destination et opérande, en déduire de quoi configurer les multiplexeurs.
[[File:Implémentation complète du contournement mémoire avec un multiplexeur.png|centre|vignette|upright=2|Implémentation complète du contournement mémoire avec un multiplexeur]]
Ce qui vient d'être dit marche uniquement si on a une unité mémoire qui fonctionne en parallèle de l'unité de calcul. Sur le pipeline RISC classique, l'unité de calcul et l'unité mémoire sont placées en série, et les choses sont plus compliquées. En effet, il y a deux cycles de différence entre l'entrée de l'unité de calcul et la sortie de l'unité mémoire. La conséquence est que la donnée lue est disponible deux cycles après l'entrée dans l'ALU, alors que l'instruction de calcul démarre un cycle après l'instruction d'accès mémoire. Il y a un cycle de différence qui fait que le résultat est connu un cycle trop tôt.
[[File:Data Forwarding (Two Stage, error).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage, error)]]
La solution est de retarder le résultat d'un cycle d'horloge en bloquant le pipeline. En clair, on économise un cycle au lieu de deux.
[[File:Data Forwarding (Two Stage).svg|centre|vignette|upright=1.5|Data Forwarding (Two Stage)]]
La commande du multiplexeur devient aussi plus compliquée. On retrouve les comparateurs entre registres destination et source, mais le délai fait que des subtilités se font jour.
===Le contournement des instructions multi-cycles===
La durée des instructions est à prendre en compte pour effectuer un contournement convenable. Le contournement entre instructions simples est facile, vu qu'elles s'exécutent en un seul cycle. Leur résultat peut être envoyé immédiatement sur leur entrée. Il n'y a aucun besoin de bloquer le pipeline. Mais avec les instructions multi-cycles, les choses sont plus compliquées, et cela mérite un exemple explicatif.
Un exemple est celui où une multiplication de 5 cycles est suivie par une addition de deux cycles. Si les deux instructions sont indépendantes, la seconde sera retardée, en raison des dépendances WAW, mais pas à cause des dépendances RAW. Voici comment sont exécutées les deux instructions :
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
Maintenant, supposons que l'addition a pour opérande le résultat de la multiplication. On voit que l'on ne peut pas reprendre tel quel le tableau précédent, car l'addition serait émise trop tôt. Il faut donc retarder encore l'addition, pour qu'elle démarre quand la multiplication vient tout juste de terminer.
{|class="wikitable"
|-
! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9
|-
| Multiplication || Multiplication || Multiplication || Multiplication || Multiplication || class="f_bleu" | Ecriture || || ||
|-
| || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || class="f_rouge" | Retard || Addition || Addition || class="f_bleu" | Ecriture ||
|}
L'unité d'émission retarde l'émission d'une instruction pendant quelques cycles, et ce n'est pas une mince affaire. Par exemple, dans l'exemple précédent, l'unité d'émission doit savoir quand le résultat de la multiplication est disponible. L'exemple précédent était simple, avec deux instructions consécutives. Maintenant, imaginez que la multiplication soit suivie par une addition, elle-même suivie d'une soustraction, et que c'est la soustraction qui utilise le résultat de la multiplication. Dans ce cas, la soustraction arrive dans l'unité d'émission deux cycles après la multiplication, elle doit être retardée de 3 cycles et non de 4. L'unité d'émission doit gérer correctement ce genre de cas.
Le contournement doit aussi tenir compte des registres d'échelonnage. Il arrive en effet qu'une instruction soit émise alors que son opérande est encore dans les registres d'échelonnage. Ce n'est pas rare, c'est même assez fréquent. Toute la difficulté est de lire le bon registre d'échelonnage, ce qui demande de faire le lien entre registre architectural et registre d'échelonnage. Les opérandes peuvent donc provenir de trois sources : des registres architecturaux, des registres d'échelonnage, du contournement direct (de la sortie aux entrées des ALUs).
Mais lire les opérandes dans les registres d'échelonnage nous emmènerait assez loin. Si les registres d'échelonnage sont regroupés dans un banc de registres, l'usage de contournement de ce genre est potentiellement équivalent à une forme de renommage de registre ! Dans les faits, disons juste qu'il n'est pas souvent implémenté. A la place, le processeur détecte ce genre de dépendance entre instruction au décodage et bloque le pipeline si besoin.
===L’émission anticipée des micro-opérations===
Idéalement, on voudrait démarrer une nouvelle micro-opération sur l'unité de calcul dès qu'elle est libre. Le problème est qu'il y a plusieurs étages entre l'unité d'émission et l'unité de calcul. Il y a au moins l'étage pour lire les registres, qui peut prendre plusieurs cycles. Il faut aussi gérer le contournement, ce qui peut prendre un cycle d'horloge. Un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Sur le Pentium 4, on trouve 6 étages entre la fenêtre d’instruction et l'entrée de l'ALU. Et cela peut poser problème pour le contournement.
Imaginons que l'on ait trois cycles entre l'unité d'émission et les ALU. Si une instruction est émise quand son opérande sort de l'ALU, elle prend plusieurs cycles avant d'arriver à l'ALU et de s'exécuter. Pendant ces cycles, l'ALU est inutilisée. Idéalement, elle aurait atteindre l'ALU immédiatement quand la première instruction en sort, pour profiter au mieux du contournement. La solution consiste à émettre des instructions en avance, le timing de l'émission est géré de manière à ce que l'instruction arrive à l'ALU pile au bon moment. La technique porte le nom d''''émission anticipée'''. Le nombre de cycles d'avance est égal au nombre d'étages entre l'unité de calcul et l'unité d'émission.
L'émission anticipée demande de connaitre à l'avance quand une instruction productrice fournira son résultat. Concrètement, c'est possible pour une instruction de calcul, mais pas pour un accès mémoire, dont le temps d'exécution est variable. Si on veut faire du contournement pour les lectures, ça ne peut être que spéculatif. Et cela nous amène à la section suivante.
Exploiter le contournement de l'unité mémoire demande de savoir combien de temps la lecture va prendre. Et le processeur n'a aucun moyen de le savoir. A la place, il spécule, comme il le fait avec la prédiction de branchement. Le processeur part du principe que l'accès mémoire fera un succès de cache, et émet les instructions lectures-dépendantes avec ce délai. Mais en cas de défaut de cache, les instructions lecture-dépendantes lisent des opérandes invalides. Et le processeur doit corriger le tir. Et pour cela, il a plusieurs solutions. Ce qui nous amène à la section suivante.
==Les défauts de cache sur un pipeline ''in-order''==
Une instruction mémoire a un temps d'exécution variable, en raison des défauts de cache. En général, elle fait un succès de cache et accède au cache L1 de données. L'accès mémoire se fait en un cycle, parfois 2 cycles si le cache est pipeliné. Mais lors d'un défaut de cache, l'instruction doit attendre plusieurs cycles : une dizaine de cycles pour lire dans le cache L2, beaucoup plus pour lire dans le L3, une bonne centaine pour la mémoire RAM. Et le processeur doit gérer la situation. Et si le défaut de cache se double d'un défaut de page mémoire, l'instruction peut dépasser le millier de cycles d'horloge.
Les pipelines précédents doivent donc gérer les ''défauts de cache'', et ceux-ci mettent plusieurs cycles pour se terminer. Un problème est que les accès mémoire ne peuvent pas être gérés comme les autres instructions multi-cycles, car ce sont des instructions au temps d'exécution variable et imprédictible. Et cela pose des problèmes sur plusieurs points. Premièrement, l'émission anticipée des micro-opérations est impactée, surtout pour contourner les lectures. Deuxièmement, cela pose des problèmes en matière d'ordre des écritures, pour les lectures et les écritures.
===Les défauts de cache en lecture causent des inversions d'écritures===
Lors d'un défaut de cache, il faut distinguer les lectures des écritures. Les écritures sont gérés par la file d'écriture, ce qui fait que si défaut de cache il y a, c'est une fois que l'écriture quitte la file d'écriture, quand l'instruction associée quitte le pipeline. Le défaut de cache peut alors être traité alors que le processeur continue d'exécuter des instructions dans son pipeline. Le processeur n'est pas bloqué, le système d'exception précise n'est pas impliqué. Par contre, pour les défauts de cache des lectures, les choses sont différentes.
Les lectures lisent une donnée et l'écrivent dans un registre, son registre de destination. Elles écrivent donc dans les registres. Et les problèmes d'ordres des écritures peuvent survenir, en cas de défaut de cache. Si une lecture fait un défaut de cache, il faudra attendre plusieurs cycles avant qu'elle écrive dans le registre de destination. Mais les instructions suivant la lecture vont continuer à s’exécuter, voire écrire dans les registres.
Un défaut de cache dure longtemps, une dizaine de cycles, voire plus. C'est souvent plus de cycles que la totalité du pipeline du processeur. Si les instructions après la lecture continuaient leur exécution, elles finiraient par atteindre la fin du pipeline. Le résultat est que ces instructions quitteraient le pipeline, écriraient dans les registres, voire dans la mémoire RAM, et ce serait définitif. Elles écriront dans les registres avant que la lecture le fasse, alors que c'est censé être l'inverse : il y a inversion de l'ordre des écritures. De plus, si la lecture déclenche une exception matérielle, on ne pourrait pas revenir en arrière, le système d'exception précise serait en erreur.
Un autre défaut, lié au précédent, est que les instructions lecture-dépendantes sont émises en avance, en supposant que la lecture fait un défaut de cache. En cas de défaut de cache, elles liront un opérande invalide. Et ces instructions invalides doivent être annulées totalement. En clair, les défauts de cache en lecture causent de nombreux problèmes : inversion de l'ordre des écritures, problèmes avec l'émission anticipée qui causent des violations de dépendances RAW. Autant dire qu'il faut corriger ce problème.
===Le blocage du pipeline par l'unité mémoire===
Les processeurs ''in-order'' disposent de deux solutions pour corriger ces problèmes. La première utilise le système d'exceptions précises. Un défaut de cache est traité comme une exception matérielle un peu particulière. Lorsque l'unité mémoire détecte un défaut de cache, elle marque la donnée lue comme invalide et lève une exception matérielle "défaut de cache". Lorsque cette exception atteint l'étage de ''writeback'', celui-ci annule toutes les instructions qui suivent. Le processeur reprend son exécution à la lecture elle-même, éventuellement après un temps d'attente, pour tenir compte de la latence du cache L2, ou de la mémoire RAM.
La seconde solution est de bloquer totalement le pipeline. Pour geler le pipeline, l'unité mémoire génère un signal ''pipeline stall''. Il est mis à 1 pendant qu'un défaut de cache est en cours, il est remis à zéro une fois le défaut de cache terminé. Ce signal est utilisé pour geler les registres du pipeline, dans les étages précédents. Seuls les étages situés avant l'étage MEM sont gelés. Ceux situés après peuvent faire leur travail normalement, ce qui permet au système d'exceptions précises de faire son travail. Les instructions précédant l'accès mémoire peuvent donc terminer leur exécution, pendant que le début du pipeline est gelé.
[[File:Implémentation d'un pipeline stall lors d'un accès mémoire.png|centre|vignette|upright=2.5|Implémentation d'un pipeline stall lors d'un accès mémoire]]
La même méthode peut être utilisée pour gérer les défauts dans le cache d'instruction. La différence est que seuls les étages avant le cache d'instruction sont gelés. Concrètement, le calcul du ''program counter'' est alors gelé tant que le défaut de cache n'est pas résolu. Vu qu'aucune donnée n'est lue, le décodeur exécute des NOPs de pipeline tant que le défaut de cache est en cours. C'est seulement quand le défaut est terminé et qu'il reçoit une nouvelle instruction qu'il cesse d'envoyer des NOPs de pipeline.
===Les optimisations : ne pas bloquer totalement le pipeline en cas de défaut de cache===
Bloquer le pipeline est une solution extrême. En théorie, il est possible de continuer à exécuter des instructions après un défaut de cache, sous certaines conditions. Il suffit juste que les instructions en question n'écrivent pas dans les registres. C'est même ce que font les processeurs à exécution dans le désordre. Ils ne sont pas bloqués après un défaut de cache. Cependant, ils peuvent le faire car ils ont un système d'exception précise élaboré, pas les processeurs ''in-order''.
Néanmoins, cela suggère une optimisation. L'idée est de continuer à exécuter des instructions après le défaut de cache. L'unité mémoire est bloquée, elle ne peut pas accepter de nouvelle instruction, mais l'ALU entière, la FPU et les autres circuits sont utilisables. Les instructions émises après le défaut de cache sont alors appelées les ''instructions en avance''. Elles n'ont pas le droit d'enregistrer leurs résultats, que ce soit dans les registres ou la RAM.
Pour cela, les micro-opérations restent dans le pipeline et sont stoppées dans le dernier étage libre. Par exemple, imaginons qu'un défaut de cache ait lieu à l'instant T, au cycle T. La lecture va se propager dans le pipeline jusqu'à l'étage de ''writeback'', dont à la toute fin du pipeline. La micro-opération suivante, émise au cycle T+2, s’arrêtera à l'étage d'avant. La micro-opération encore avant s’arrêtera à l'étage encore avant, et ainsi de suite. Le pipeline finira par se remplir totalement, et l'unité d'émission n'aura pas le choix que de bloquer le pipeline.
Le résultat est que des micro-opérations auront été préchargées et exécutées à l'avance, mais ne se seront pas "terminées". Il y a toujours la possibilité de les annuler, en cas de problèmes. Le nombre de micro-opérations chargées en avance est égal au nombre d'étage du pipeline, vu qu'il est totalement remplit. Cette optimisation a possiblement été utilisée par les processeurs ARM de marque Cortex A53, A55 et A510, bien que ce ne soit pas certain (il s'agit d'une supposition fite par l'auteur du bloc "Chips and cheese". Le Cortex A52 et le A55 pouvaient exécuter en avance 8 instructions, et ils avaient un pipeline de 8 étages, alors que le A510 pouvait avoir 12 instructions en avance, et il avait justement un pipeline de 12 étages.
{|class="wikitable"
|-
! Fetch 1 !! Fetch 2 !! Décodage 1 !! Décodage 2 !! Émission !! Lecture des registres !! Exécution !! ''Writeback''
|-
| Micro-opération T + 8 || Micro-opération T + 7 || Micro-opération T + 6 || Micro-opération T + 5 || Micro-opération T + 4 || Micro-opération T + 3 || Micro-opération T + 2 || Micro-opération T + 1 || colspan="8" class="f_rouge" | Lecture, en défaut au cycle T
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'émission des instructions
| prevText=L'émission des instructions
| next=Le scoreboard et le ROB
| nextText=Le scoreboard et le ROB
}}
</noinclude>
nxlil7n2k1le5kg3ckbhp86d2fxmsfp
Mathc initiation/007q
0
84573
775228
775045
2026-10-13T19:40:57Z
Xhungab
23827
775228
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007n| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c2fsap.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c2fsap.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 10
/* ---------------------------------- */
#include "f2.h"
/* ---------------------------------- */
int main(void)
{
double T = 2*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
if (fabs(A[n])<(+1.000e-10)) A[n] = 0.;
if (fabs(B[n])<(+1.000e-10)) B[n] = 0.;
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s t = [-PI..PI]\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n an bn Amplitude Phase\n\n");
for( n=1; n<N; n++)
printf("%2d |%+7.3e | %+7.3e | %+7.3e | %+7.3e rad"
" = %+7.2f°\n",
n, A[n],B[n], sqrt(A[n]*A[n]+B[n]*B[n]), atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
printf("\n\n");
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> (t^2)) t = [-PI..PI]
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +3.289868134
n an bn Amplitude Phase
1 |-4.000e+00 | +0.000e+00 | +4.000e+00 | +3.142e+00 rad = +180.00°
2 |+1.000e+00 | +0.000e+00 | +1.000e+00 | +0.000e+00 rad = +0.00°
3 |-4.444e-01 | +0.000e+00 | +4.444e-01 | +3.142e+00 rad = +180.00°
4 |+2.500e-01 | +0.000e+00 | +2.500e-01 | +0.000e+00 rad = +0.00°
5 |-1.600e-01 | +0.000e+00 | +1.600e-01 | +3.142e+00 rad = +180.00°
6 |+1.111e-01 | +0.000e+00 | +1.111e-01 | +0.000e+00 rad = +0.00°
7 |-8.163e-02 | +0.000e+00 | +8.163e-02 | +3.142e+00 rad = +180.00°
8 |+6.250e-02 | +0.000e+00 | +6.250e-02 | +0.000e+00 rad = +0.00°
9 |-4.938e-02 | +0.000e+00 | +4.938e-02 | +3.142e+00 rad = +180.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
g05h7oxyqyo75nc700oqpwhbl4o8wcq
Mathc initiation/a9c
0
84574
775235
775046
2026-10-13T19:43:42Z
Xhungab
23827
775235
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a602| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c1fsap.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c1fsap.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 10
/* ---------------------------------- */
#include "f1.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.;
double omega = (2.*PI/T);
double a = -1;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
if (fabs(A[n])<(+1.000e-10)) A[n] = 0.;
if (fabs(B[n])<(+1.000e-10)) B[n] = 0.;
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s t = [-1..1]\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n an bn Amplitude Phase\n\n");
for( n=1; n<N; n++)
printf("%2d |%+7.3e | %+7.3e | %+7.3e | %+7.3e rad"
" = %+7.2f°\n",
n, A[n],B[n], sqrt(A[n]*A[n]+B[n]*B[n]), atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
printf("\n\n");
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="c">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> (x(x+1)) t = [-1..1]
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.333333333
n an bn Amplitude Phase
1 |-4.053e-01 | +6.366e-01 | +7.547e-01 | +2.138e+00 rad = +122.48°
2 |+1.013e-01 | -3.183e-01 | +3.340e-01 | -1.263e+00 rad = -72.34°
3 |-4.503e-02 | +2.122e-01 | +2.169e-01 | +1.780e+00 rad = +101.98°
4 |+2.533e-02 | -1.592e-01 | +1.612e-01 | -1.413e+00 rad = -80.96°
5 |-1.621e-02 | +1.273e-01 | +1.284e-01 | +1.697e+00 rad = +97.26°
6 |+1.126e-02 | -1.061e-01 | +1.067e-01 | -1.465e+00 rad = -83.94°
7 |-8.271e-03 | +9.095e-02 | +9.132e-02 | +1.661e+00 rad = +95.20°
8 |+6.332e-03 | -7.958e-02 | +7.983e-02 | -1.491e+00 rad = -85.45°
9 |-5.003e-03 | +7.074e-02 | +7.091e-02 | +1.641e+00 rad = +94.05°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
f1meejuww14d0hqdw35bg18dn6ao5mx
Mathc initiation/a9d
0
84575
775213
775043
2026-10-13T19:32:51Z
Xhungab
23827
775213
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a593| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c0fsap.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c0fsap.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 10
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = PI;
double omega = (2.*PI/T);
double a = 0.;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
if (fabs(A[n])<(+1.000e-10)) A[n] = 0.;
if (fabs(B[n])<(+1.000e-10)) B[n] = 0.;
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s t = [0..PI]\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n an bn Amplitude Phase\n\n");
for( n=1; n<N; n++)
printf("%2d |%+7.3e | %+7.3e | %+7.3e | %+7.3e rad"
" = %+7.2f°\n",
n, A[n],B[n], sqrt(A[n]*A[n]+B[n]*B[n]), atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
printf("\n\n");
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> t t = [0..PI]
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +1.570796327
n an bn Amplitude Phase
1 |+0.000e+00 | -1.000e+00 | +1.000e+00 | -1.571e+00 rad = -90.00°
2 |+0.000e+00 | -5.000e-01 | +5.000e-01 | -1.571e+00 rad = -90.00°
3 |+0.000e+00 | -3.333e-01 | +3.333e-01 | -1.571e+00 rad = -90.00°
4 |+0.000e+00 | -2.500e-01 | +2.500e-01 | -1.571e+00 rad = -90.00°
5 |+0.000e+00 | -2.000e-01 | +2.000e-01 | -1.571e+00 rad = -90.00°
6 |+0.000e+00 | -1.667e-01 | +1.667e-01 | -1.571e+00 rad = -90.00°
7 |+0.000e+00 | -1.429e-01 | +1.429e-01 | -1.571e+00 rad = -90.00°
8 |+0.000e+00 | -1.250e-01 | +1.250e-01 | -1.571e+00 rad = -90.00°
9 |+0.000e+00 | -1.111e-01 | +1.111e-01 | -1.571e+00 rad = -90.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
16vaki4hvcv03cfwry0fadw280g1wbi
Mathc initiation/007s
0
84578
775215
774887
2026-10-13T19:34:54Z
Xhungab
23827
775215
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3a0.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double a = -PI;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a, b);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a0 = 1/T int( (%s) dx = %.6f\n"
" (a \n\n\n\n", feq, a0);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( (t) dx = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
emd1vtazajbo208as5vy0k1650vcfje
Mathc initiation/007t
0
84579
775216
774888
2026-10-13T19:35:17Z
Xhungab
23827
775216
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3an.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double n = 1.; /* You can change the value of n */
double an = 2./T * fourier_an(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" a_%.0f = 2/T int( (%s) cos(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n",n, feq, n, an);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a_1 = 2/T int( (t) cos(1 omega t) dt = 0.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
n3ijywnwufgdsumc12jd91bfv2lfq6t
Mathc initiation/007u
0
84580
775217
774889
2026-10-13T19:35:47Z
Xhungab
23827
775217
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3bn.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double n = 1.; /* You can change the value of n */
double bn = 2./T * fourier_bn(f, a,b, omega, n);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" (a + T\n"
" b_%.0f = 2/T int( (%s) sin(%.0f omega t) dt = %.6f\n"
" (a \n\n\n\n", n, feq, n, bn);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
b_1 = 2/T int( (t) sin(1 omega t) dt = 2.000000
(a
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
eu9zpj61uu5rewn0ggifo5zpgiauc66
Mathc initiation/007v
0
84581
775218
774891
2026-10-13T19:36:26Z
Xhungab
23827
775218
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier|c3fs.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fs.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double a0 = 1./T * fourier_a0(f, a,b);
double a1 = 2./T * fourier_an(f, a,b, omega, 1);
double a2 = 2./T * fourier_an(f, a,b, omega, 2);
double a3 = 2./T * fourier_an(f, a,b, omega, 3);
double a4 = 2./T * fourier_an(f, a,b, omega, 4);
double a5 = 2./T * fourier_an(f, a,b, omega, 5);
double a6 = 2./T * fourier_an(f, a,b, omega, 6);
double a7 = 2./T * fourier_an(f, a,b, omega, 7);
double a8 = 2./T * fourier_an(f, a,b, omega, 8);
double b1 = 2./T * fourier_bn(f, a,b, omega, 1);
double b2 = 2./T * fourier_bn(f, a,b, omega, 2);
double b3 = 2./T * fourier_bn(f, a,b, omega, 3);
double b4 = 2./T * fourier_bn(f, a,b, omega, 4);
double b5 = 2./T * fourier_bn(f, a,b, omega, 5);
double b6 = 2./T * fourier_bn(f, a,b, omega, 6);
double b7 = 2./T * fourier_bn(f, a,b, omega, 7);
double b8 = 2./T * fourier_bn(f, a,b, omega, 8);
clrscrn();
printf(" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n\n");
printf(" a0 = %.6f\n\n\n",a0);
printf(" %+.9f\\\n"
" %+.9f*cos(%.9f* 1*x) %+.9f*sin(%.9f* 1*x)\\\n"
" %+.9f*cos(%.9f* 2*x) %+.9f*sin(%.9f* 2*x)\\\n"
" %+.9f*cos(%.9f* 3*x) %+.9f*sin(%.9f* 3*x)\\\n"
" %+.9f*cos(%.9f* 4*x) %+.9f*sin(%.9f* 4*x)\\\n"
" %+.9f*cos(%.9f* 5*x) %+.9f*sin(%.9f* 5*x)\\\n"
" %+.9f*cos(%.9f* 6*x) %+.9f*sin(%.9f* 6*x)\\\n"
" %+.9f*cos(%.9f* 7*x) %+.9f*sin(%.9f* 7*x)\\\n"
" %+.9f*cos(%.9f* 8*x) %+.9f*sin(%.9f* 8*x)\n\n",
a0,
a1,omega, b1,omega,
a2,omega, b2,omega,
a3,omega, b3,omega,
a4,omega, b4,omega,
a5,omega, b5,omega,
a6,omega, b6,omega,
a7,omega, b7,omega,
a8,omega, b8,omega);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
a0 = 0.000000
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
cpo920e5p6j8dek9l9z993y1dmbu029
Mathc initiation/007w
0
84582
775219
774894
2026-10-13T19:37:05Z
Xhungab
23827
775219
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier| c3fsN.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fsN.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 50
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
}
clrscrn();
printf(" %+.9f\\\n", A[0]);
for( n=1; n<N; n++)
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\\\n",
A[n], omega, n,
B[n], omega, n);
printf(" %+.9f*cos(%.9f* %d*x) %+.9f*sin(%.9f* %d*x)\n",
A[n], omega, n,
B[n], omega, n);
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
+0.000000000\
+0.000000000*cos(1.000000000* 1*x) +2.000000011*sin(1.000000000* 1*x)\
+0.000000000*cos(1.000000000* 2*x) -1.000000087*sin(1.000000000* 2*x)\
+0.000000000*cos(1.000000000* 3*x) +0.666666959*sin(1.000000000* 3*x)\
-0.000000000*cos(1.000000000* 4*x) -0.500000694*sin(1.000000000* 4*x)\
+0.000000000*cos(1.000000000* 5*x) +0.400001357*sin(1.000000000* 5*x)\
-0.000000000*cos(1.000000000* 6*x) -0.333335681*sin(1.000000000* 6*x)\
+0.000000000*cos(1.000000000* 7*x) +0.285718020*sin(1.000000000* 7*x)\
+0.000000000*cos(1.000000000* 8*x) -0.250005583*sin(1.000000000* 8*x)
...
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
e19nbz4c59wdse7v1az59y8yl09kyze
Mathc initiation/007x
0
84583
775221
775044
2026-10-13T19:37:33Z
Xhungab
23827
775221
wikitext
text/x-wiki
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/007y| Sommaire]]
Installer et compiler ces fichiers dans votre répertoire de travail.
{{Fichier| c3fsap.c|largeur=70%|info=|icon=Crystal128-source-c.svg}}
<syntaxhighlight lang="c">
/* ---------------------------------- */
/* save as c3fsap.c */
/* ---------------------------------- */
#include "x_hfile.h"
/* ---------------------------------- */
#define N 10
/* ---------------------------------- */
#include "f0.h"
/* ---------------------------------- */
int main(void)
{
double T = 2.*PI;
double omega = (2.*PI/T);
double a = -PI;
double b = a+T;
double A[N+1];
double B[N+1];
int n = 0;
A[0] = 1./T * fourier_a0(f, a,b);
for( n=1; n<=N; n++)
{
A[n] = 2./T * fourier_an(f, a,b, omega, n);
B[n] = 2./T * fourier_bn(f, a,b, omega, n);
if (fabs(A[n])<(+1.000e-10)) A[n] = 0.;
if (fabs(B[n])<(+1.000e-10)) B[n] = 0.;
}
clrscrn();
printf(" Fourier Series\n\n"
" _+oo_ \n"
" \\ \n"
" S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n"
" /_____ \n"
" n = 1 \n\n\n"
" (a + T \n"
" a0 = 1/T int( f(t) dx\n"
" (a \n\n\n"
" (a + T \n"
" an = 2/T int( f(t) cos(n omega t) dt\n"
" (a \n\n"
" (a + T \n"
" bn = 2/T int( f(t) sin(n omega t) dt\n"
" (a \n\n");
stop();
clrscrn();
printf(" With f : t-> %s t = [-PI..PI]\n\n"
" Amplitude spectrum : sqrt(an^2 + bn^2) \n"
" Phase spectrum : atan2(bn,an) \n\n",feq);
printf(" A[0] = a0 = %+.9f\n\n", A[0]);
printf(" n an bn Amplitude Phase\n\n");
for( n=1; n<N; n++)
printf("%2d |%+7.3e | %+7.3e | %+7.3e | %+7.3e rad"
" = %+7.2f°\n",
n, A[n],B[n], sqrt(A[n]*A[n]+B[n]*B[n]), atan2(B[n],A[n]),
atan2(B[n],A[n])*180/PI);
printf("\n\n");
stop();
return 0;
}
/* ---------------------------------- */
/* ---------------------------------- */
</syntaxhighlight>
'''Exemple de sortie écran :'''
<syntaxhighlight lang="C">
Fourier Series
_+oo_
\
S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)
/_____
n = 1
(a + T
a0 = 1/T int( f(t) dx
(a
(a + T
an = 2/T int( f(t) cos(n omega t) dt
(a
(a + T
bn = 2/T int( f(t) sin(n omega t) dt
(a
Press return to continue.
With f : t-> t t = [-PI..PI]
Amplitude spectrum : sqrt(an^2 + bn^2)
Phase spectrum : atan2(bn,an)
A[0] = a0 = +0.000000000
n an bn Amplitude Phase
1 |+0.000e+00 | +2.000e+00 | +2.000e+00 | +1.571e+00 rad = +90.00°
2 |+0.000e+00 | -1.000e+00 | +1.000e+00 | -1.571e+00 rad = -90.00°
3 |+0.000e+00 | +6.667e-01 | +6.667e-01 | +1.571e+00 rad = +90.00°
4 |+0.000e+00 | -5.000e-01 | +5.000e-01 | -1.571e+00 rad = -90.00°
5 |+0.000e+00 | +4.000e-01 | +4.000e-01 | +1.571e+00 rad = +90.00°
6 |+0.000e+00 | -3.333e-01 | +3.333e-01 | -1.571e+00 rad = -90.00°
7 |+0.000e+00 | +2.857e-01 | +2.857e-01 | +1.571e+00 rad = +90.00°
8 |+0.000e+00 | -2.500e-01 | +2.500e-01 | -1.571e+00 rad = -90.00°
9 |+0.000e+00 | +2.222e-01 | +2.222e-01 | +1.571e+00 rad = +90.00°
Press return to continue.
</syntaxhighlight>
{{AutoCat}}
7id69rvdz59g8mg1y49sxoaikyy6g05
Mathc initiation/0081
0
84590
775134
2026-10-13T12:27:59Z
Xhungab
23827
news
775134
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a594#Quelques concepts :| Sommaire]]
==Etudions le rôle des harmoniques==
'''Soit un signal :'''
* S(x) = plot 4sin(x)/pi + 4sin(3x)/(3pi) + 4sin(5x)/(5pi) + 4sin(7x)/(7pi)
Dessinons avec [https://www.wolframalpha.com/ Mathematica] :
'''Une seule harmonique :''' C'est une sinusoïde.
'''* S1(x) :''' plot 4*sin(x)/pi
'''Avec la troisième harmonique :''' Le signal commence à se dessiner.
'''* S3(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi)
'''Et la cinquième harmonique :'''
'''* S5(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi) + 4*sin(5*x)/(5*pi)
'''Plus La septième harmonique :'''
'''* S7(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi) + 4*sin(5*x)/(5*pi) + 4*sin(7*x)/(7*pi)
'''Chaque harmonique apporte une contribution supplémentaire. C'est leur somme qui permet de reconstruire le signal.'''
{{AutoCat}}
bvf2sbsogwd6ii2797wed4fu21tgkje
775139
775134
2026-10-13T12:53:00Z
Xhungab
23827
775139
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a594#Quelques concepts :| Sommaire]]
==Etudions le rôle des harmoniques==
'''Soit un signal :'''
* S(x) = plot 4sin(x)/pi + 4sin(3x)/(3pi) + 4sin(5x)/(5pi) + 4sin(7x)/(7pi)
Dessinons avec [https://www.wolframalpha.com/ Mathematica] :
'''Une seule harmonique :''' C'est une sinusoïde.
'''* S1(x) :''' plot 4*sin(x)/pi
'''Avec la troisième harmonique :''' Le signal commence à se dessiner.
'''* S3(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi)
'''Et la cinquième harmonique :'''
'''* S5(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi) + 4*sin(5*x)/(5*pi)
'''Plus La septième harmonique :'''
'''* S7(x) :''' plot 4*sin(x)/pi + 4*sin(3*x)/(3*pi) + 4*sin(5*x)/(5*pi) + 4*sin(7*x)/(7*pi)
'''Chaque harmonique ajouté, apporte une contribution supplémentaire. C'est leur somme qui permet de reconstruire le signal.'''
{{AutoCat}}
9t0zhszyu3xd6ecrdoau414a4e9l1f6
Mathc initiation/0082
0
84591
775137
2026-10-13T12:43:38Z
Xhungab
23827
news
775137
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a594#Quelques concepts :| Sommaire]]
==Étudions la notion de fréquences==
'''Considérons les signaux entre -Pi et Pi :'''
Dessinons les avec [https://www.wolframalpha.com/ Mathematica] ):
'''sin(t)''', il y a une période.
'''* S1(x) :''' plot sin(t)
'''sin(3t)''', il y a trois périodes.
'''* S3(x) :''' plot sin(t) ; sin(3*t)
'''sin(5t)''', il y a cinq périodes.
'''* S5(x) :''' plot sin(t) ; sin(5*t)
'''Plus le rang de l'harmonique augmente, plus sa fréquence augmente.'''
'''Remarque 1 : L'harmonique fondamentale est la sinusoïde de fréquence de base. Les harmoniques suivantes ont des des fréquence plus hautes. Elles ajoutent des détails, qui permettent de mieux reproduire la forme du signal.'''
'''Remarque2 : Le phénomène de Gibbs ce sont de petites oscillations en bout des signaux. Ce phénomène persistent, même lorsqu'on ajoute beaucoup d'harmoniques.'''
{{AutoCat}}
isc7ivva4hqj6nivmiw5z3l4wgxent1
775138
775137
2026-10-13T12:44:51Z
Xhungab
23827
775138
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a594#Quelques concepts :| Sommaire]]
==Étudions la notion de fréquences==
'''Considérons les signaux entre -Pi et Pi :'''
Dessinons les avec [https://www.wolframalpha.com/ Mathematica] ):
'''sin(t)''', il y a une période entre -Pi et Pi.
'''* S1(x) :''' plot sin(t)
'''sin(3t)''', il y a trois périodes entre -Pi et Pi.
'''* S3(x) :''' plot sin(t) ; sin(3*t)
'''sin(5t)''', il y a cinq périodes entre -Pi et Pi.
'''* S5(x) :''' plot sin(t) ; sin(5*t)
'''Plus le rang de l'harmonique augmente, plus sa fréquence augmente.'''
'''Remarque 1 : L'harmonique fondamentale est la sinusoïde de fréquence de base. Les harmoniques suivantes ont des des fréquence plus hautes. Elles ajoutent des détails, qui permettent de mieux reproduire la forme du signal.'''
'''Remarque2 : Le phénomène de Gibbs ce sont de petites oscillations en bout des signaux. Ce phénomène persistent, même lorsqu'on ajoute beaucoup d'harmoniques.'''
{{AutoCat}}
gcjqludboeoyq4of61twte8tlzfgp4o
Mathc initiation/0083
0
84592
775141
2026-10-13T13:38:15Z
Xhungab
23827
news
775141
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
[[Mathc initiation/a594#Quelques concepts :| Sommaire]]
==Étudions le rôle de la phase==
'''x(t)=A sin(Ωt+Φ)'''
A est l''''amplitude'''.
Ω est la '''pulsation'''.
Φ est la '''phase'''.
Dessinons avec [https://www.wolframalpha.com/ Mathematica] :
* : plot sin(t) ; sin(t+pi/2)
'''+pi/2'''>0, le signal est décalé vers la gauche. L'invité '''arrive plutôt''',
il est '''en avance'''.
* : plot sin(t) ; sin(t-pi/2)
'''-pi/2'''<0, le signal est décalé vers la droite. L'invité '''arrive (moins tôt) plus tard''' ,
il est '''en retard'''.
'''Remarque : Ce changement de phase permet le passage de la fonction sinus à la fonction cosinus'''
* plot cos(t) ; sin(t+pi/2)
* plot -cos(t) ; sin(t-pi/2)
{{AutoCat}}
2o14sqzdvruyqwzhnrnej6rygmpm5dk
Mathc initiation/0084
0
84593
775254
2026-10-14T10:19:17Z
Xhungab
23827
news
775254
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a10| Sommaire]]
:
La transformée de Laplace permet de transformer une équation différentielle linéaire à coefficients constants en une équation algébrique plus simple à résoudre, tout en intégrant directement les conditions initiales [https://en.wikipedia.org/wiki/Laplace_transform_applied_to_differential_equations Wikipédia]
{{Partie{{{type|}}}|La méthode de la transformée de Laplace}}
:
{{Partie{{{type|}}}|[[Mathc initiation/a547|* Résoudre : y(t)'+y(t)=1 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/a00ad|* Résoudre : y(t)'+3 y(t)=6 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006f|* Résoudre : y(t)'+2 y(t)=4 avec y(0) = 1]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/006l|* Résoudre : y(t)'+2y(t)=exp(-t) avec y(0) = 1]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006j|* Résoudre : y(t)'+3y(t)=exp(-2t) avec y(0) = 2]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a608|* Résoudre : y(t) ' ' - 9 y(t) = 0 avec y(0) = 1 et y(0)' = 3]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006h|* Résoudre : y(t) ' ' + 3 y(t)' + 2 y(t) = 0 avec y(0)=1 y(0)'=0]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a613|* Résoudre un sytème d'équations différentielles]]}}
{{AutoCat}}
n72ittsgp6j3vve3sg3vgdix2i15rxl
775255
775254
2026-10-14T10:23:09Z
Xhungab
23827
775255
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a10| Sommaire]]
:
La transformée de Laplace permet de transformer une équation différentielle linéaire à coefficients constants en une équation algébrique plus simple à résoudre, tout en intégrant directement les conditions initiales.
Une introduction à la[[#Transformée de Laplace :| '''transformée de Laplace''']] sur Wikibook.
{{Partie{{{type|}}}|La méthode de la transformée de Laplace}}
:
{{Partie{{{type|}}}|[[Mathc initiation/a547|* Résoudre : y(t)'+y(t)=1 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/a00ad|* Résoudre : y(t)'+3 y(t)=6 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006f|* Résoudre : y(t)'+2 y(t)=4 avec y(0) = 1]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/006l|* Résoudre : y(t)'+2y(t)=exp(-t) avec y(0) = 1]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006j|* Résoudre : y(t)'+3y(t)=exp(-2t) avec y(0) = 2]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a608|* Résoudre : y(t) ' ' - 9 y(t) = 0 avec y(0) = 1 et y(0)' = 3]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006h|* Résoudre : y(t) ' ' + 3 y(t)' + 2 y(t) = 0 avec y(0)=1 y(0)'=0]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a613|* Résoudre un sytème d'équations différentielles]]}}
{{AutoCat}}
07dx4uqj4h6r4b3k9gy166cgk3fu6uh
775256
775255
2026-10-14T10:24:11Z
Xhungab
23827
775256
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a10| Sommaire]]
:
La transformée de Laplace permet de transformer une équation différentielle linéaire à coefficients constants en une équation algébrique plus simple à résoudre, tout en intégrant directement les conditions initiales.
Une introduction à la [[Mathc initiation/a522#Transformée de Laplace :| '''transformée de Laplace''']] sur Wikibook.
{{Partie{{{type|}}}|La méthode de la transformée de Laplace}}
:
{{Partie{{{type|}}}|[[Mathc initiation/a547|* Résoudre : y(t)'+y(t)=1 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/a00ad|* Résoudre : y(t)'+3 y(t)=6 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006f|* Résoudre : y(t)'+2 y(t)=4 avec y(0) = 1]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/006l|* Résoudre : y(t)'+2y(t)=exp(-t) avec y(0) = 1]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006j|* Résoudre : y(t)'+3y(t)=exp(-2t) avec y(0) = 2]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a608|* Résoudre : y(t) ' ' - 9 y(t) = 0 avec y(0) = 1 et y(0)' = 3]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006h|* Résoudre : y(t) ' ' + 3 y(t)' + 2 y(t) = 0 avec y(0)=1 y(0)'=0]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a613|* Résoudre un sytème d'équations différentielles]]}}
{{AutoCat}}
t69pai6jb238vgox6ij0f6oiytc2m1j
775257
775256
2026-10-14T10:28:11Z
Xhungab
23827
775257
wikitext
text/x-wiki
__NOTOC__
[[Catégorie:Mathc initiation (livre)]]
:
[[Mathc initiation/a10| Sommaire]]
:
La transformée de Laplace permet de transformer une équation différentielle linéaire à coefficients constants en une équation algébrique plus simple à résoudre, tout en intégrant directement les conditions initiales.
Une introduction à la [[Mathc initiation/a522#Transformée de Laplace :| '''transformée de Laplace''']] sur Wikibook.
{{Partie{{{type|}}}|La méthode de la transformée de Laplace}}
:
{{Partie{{{type|}}}|[[Mathc initiation/a547|* Résoudre : y(t)'+y(t)=1 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/a00ad|* Résoudre : y(t)'+3 y(t)=6 avec y(0) = 0]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006f|* Résoudre : y(t)'+2 y(t)=4 avec y(0) = 1]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/006l|* Résoudre : y(t)'+2y(t)=exp(-t) avec y(0) = 1]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006j|* Résoudre : y(t)'+3y(t)=exp(-2t) avec y(0) = 2]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a608|* Résoudre : y(t) ' ' - 9 y(t) = 0 avec y(0) = 1 et y(0)' = 3]]}}
{{Partie{{{type|}}}|[[Mathc initiation/006h|* Résoudre : y(t) ' ' + 3 y(t)' + 2 y(t) = 0 avec y(0)=1 y(0)'=0]]}}
:
.
:
{{Partie{{{type|}}}|[[Mathc initiation/a613|* Résoudre un sytème d'équations différentielles]]}}
:
.
:
'''Quelques rappels mathématiques : '''
{{Partie{{{type|}}}|[[Mathc initiation/a566|* Trigonométriques. Trigonométriques hyperboliques]]}}
{{Partie{{{type|}}}|[[Mathc initiation/a569|* Fractions partielles]]}}
:
.
:
{{AutoCat}}
hbjvuvf8grzeemkygzhebzxkg1aenxx